提交所有代码到 qiaoxinjiu 分支
This commit is contained in:
@@ -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>
|
||||
```
|
||||
|
||||
当前机制:
|
||||
|
||||
- token 存 Redis
|
||||
- 默认有效期:2 小时
|
||||
- 剩余有效期小于 30 分钟时,访问受保护接口会自动续期
|
||||
- 注册、登录接口不需要 token
|
||||
- 其他接口已逐步接入登录鉴权与权限限制
|
||||
|
||||
---
|
||||
***
|
||||
|
||||
## 11. 当前初始化 SQL 已包含的业务菜单
|
||||
|
||||
已补入以下可直接录入的菜单数据:
|
||||
|
||||
### 系统管理
|
||||
|
||||
- `system` 系统管理
|
||||
- `role_manage` 角色管理
|
||||
- `user_manage` 用户管理
|
||||
@@ -788,6 +821,7 @@ Authorization: Bearer <token>
|
||||
- `menu_manage` 菜单管理
|
||||
|
||||
### 测试平台
|
||||
|
||||
- `test_platform` 测试平台
|
||||
- `product_manage` 产品管理
|
||||
- `project_manage` 项目管理
|
||||
@@ -796,6 +830,7 @@ Authorization: Bearer <token>
|
||||
- `report_manage` 测试报告
|
||||
|
||||
### 造数工具
|
||||
|
||||
- `data_tools` 造数工具
|
||||
- `data_builder_manage` 数据库造数
|
||||
- `data_factory_manage` 造数工厂
|
||||
@@ -805,3 +840,4 @@ Authorization: Bearer <token>
|
||||
1. Swagger/OpenAPI 版本
|
||||
2. Apifox / Postman 导入版
|
||||
3. 初始化权限菜单角色的更完整种子数据
|
||||
|
||||
|
||||
2051
.plan/1ZFcjkLpHmrHbluVoOtA4.md
Normal file
2051
.plan/1ZFcjkLpHmrHbluVoOtA4.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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 接口的稳定性、可靠性和安全性,为前端应用提供可靠的后端支持。
|
||||
@@ -15,4 +15,4 @@ def create_app():
|
||||
)
|
||||
app.config["API_DOC_MEMBER"] = ["api", "platform"]
|
||||
logger.info("app start-------")
|
||||
return app
|
||||
return app
|
||||
Binary file not shown.
BIN
app/__pycache__/scheduler.cpython-38.pyc
Normal file
BIN
app/__pycache__/scheduler.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
53
app/api/controller/automationController.py
Normal file
53
app/api/controller/automationController.py
Normal file
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""查询计划进度统计。"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ''
|
||||
|
||||
BIN
app/api/dao/__pycache__/automationDao.cpython-38.pyc
Normal file
BIN
app/api/dao/__pycache__/automationDao.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
129
app/api/dao/automationDao.py
Normal file
129
app/api/dao/automationDao.py
Normal file
@@ -0,0 +1,129 @@
|
||||
# encoding: UTF-8
|
||||
from sqlalchemy import func
|
||||
|
||||
from logger import logger
|
||||
from ..model.automationModel import AutoExecution, AutoExecutionCase
|
||||
from ..model.caseModel import TestCase
|
||||
from ..model.planModel import PlanCase, TestPlan
|
||||
|
||||
|
||||
class AutomationDao(object):
|
||||
@staticmethod
|
||||
def create_execution(session, add_info):
|
||||
obj = AutoExecution(**add_info)
|
||||
session.add(obj)
|
||||
err = session.done(close=False)
|
||||
if err:
|
||||
logger.warning(f'AutoExecution新增失败!{err}')
|
||||
return 0, f'新增失败!{err}'
|
||||
return obj, ''
|
||||
|
||||
@staticmethod
|
||||
def batch_create_execution_cases(session, batch_info_list):
|
||||
if not batch_info_list:
|
||||
return [], ''
|
||||
objs = [AutoExecutionCase(**info) for info in batch_info_list]
|
||||
session.add_all(objs)
|
||||
err = session.done(close=False)
|
||||
if err:
|
||||
logger.warning(f'AutoExecutionCase批量新增失败!{err}')
|
||||
return [], f'批量新增失败!{err}'
|
||||
return objs, ''
|
||||
|
||||
@staticmethod
|
||||
def update_execution_by_id(session, execution_id, update_info):
|
||||
update_res = session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).update(update_info)
|
||||
err = session.done(close=False)
|
||||
if err:
|
||||
logger.error(f'AutoExecution更新失败!id: {execution_id}, err: {err}')
|
||||
return 0, f'更新失败!{err}'
|
||||
if not update_res:
|
||||
return 0, '未查询到对应执行记录!'
|
||||
return int(execution_id), ''
|
||||
|
||||
@staticmethod
|
||||
def get_execution_by_id(session, execution_id):
|
||||
return session.query(AutoExecution).filter(AutoExecution.id == int(execution_id)).first()
|
||||
|
||||
@staticmethod
|
||||
def list_execution_by_filters(session, filters, page=1, limit=20):
|
||||
query = session.query(AutoExecution).filter(*filters)
|
||||
total = query.count()
|
||||
items = query.order_by(AutoExecution.created_time.desc()).offset((int(page) - 1) * int(limit)).limit(int(limit)).all()
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def get_execution_case_by_id(session, execution_case_id):
|
||||
return session.query(AutoExecutionCase).filter(AutoExecutionCase.id == int(execution_case_id)).first()
|
||||
|
||||
@staticmethod
|
||||
def get_execution_case_by_unique(session, execution_id, case_id, plan_case_id=None):
|
||||
filters = [AutoExecutionCase.execution_id == int(execution_id), AutoExecutionCase.case_id == int(case_id)]
|
||||
if plan_case_id:
|
||||
filters.append(AutoExecutionCase.plan_case_id == int(plan_case_id))
|
||||
return session.query(AutoExecutionCase).filter(*filters).order_by(AutoExecutionCase.id.asc()).first()
|
||||
|
||||
@staticmethod
|
||||
def update_execution_case_by_id(session, execution_case_id, update_info):
|
||||
update_res = session.query(AutoExecutionCase).filter(AutoExecutionCase.id == int(execution_case_id)).update(update_info)
|
||||
err = session.done(close=False)
|
||||
if err:
|
||||
logger.error(f'AutoExecutionCase更新失败!id: {execution_case_id}, err: {err}')
|
||||
return 0, f'更新失败!{err}'
|
||||
if not update_res:
|
||||
return 0, '未查询到对应执行明细!'
|
||||
return int(execution_case_id), ''
|
||||
|
||||
@staticmethod
|
||||
def list_execution_case_by_filters(session, filters, page=1, limit=20):
|
||||
query = session.query(AutoExecutionCase).filter(*filters)
|
||||
total = query.count()
|
||||
items = query.order_by(AutoExecutionCase.id.asc()).offset((int(page) - 1) * int(limit)).limit(int(limit)).all()
|
||||
return items, total
|
||||
|
||||
@staticmethod
|
||||
def count_execution_case_summary(session, execution_id):
|
||||
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)
|
||||
summary['total'] = sum(summary.values())
|
||||
return summary
|
||||
|
||||
@staticmethod
|
||||
def query_case_auto_item(session, case_id):
|
||||
return session.query(TestCase).filter(
|
||||
TestCase.id == int(case_id), TestCase.is_delete == 0, TestCase.is_auto == 1
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def query_plan_auto_cases(session, plan_id, round_no=None, case_ids=None):
|
||||
query = session.query(PlanCase, TestCase).join(
|
||||
TestCase, PlanCase.case_id == TestCase.id
|
||||
).filter(
|
||||
PlanCase.plan_id == int(plan_id),
|
||||
TestCase.is_delete == 0,
|
||||
TestCase.is_auto == 1
|
||||
)
|
||||
if round_no not in (None, ''):
|
||||
query = query.filter(PlanCase.round_no == int(round_no))
|
||||
if case_ids:
|
||||
query = query.filter(PlanCase.case_id.in_([int(case_id) for case_id in case_ids]))
|
||||
return query.order_by(PlanCase.id.asc()).all()
|
||||
|
||||
@staticmethod
|
||||
def update_plan_case_result(session, plan_case_id, update_info):
|
||||
update_res = session.query(PlanCase).filter(PlanCase.id == int(plan_case_id)).update(update_info)
|
||||
err = session.done(close=False)
|
||||
if err:
|
||||
logger.error(f'PlanCase更新失败!id: {plan_case_id}, err: {err}')
|
||||
return 0, f'更新失败!{err}'
|
||||
if not update_res:
|
||||
return 0, '未查询到对应计划用例!'
|
||||
return int(plan_case_id), ''
|
||||
|
||||
@staticmethod
|
||||
def get_plan_by_id(session, plan_id):
|
||||
return session.query(TestPlan).filter(TestPlan.id == int(plan_id), TestPlan.is_delete == 0).first()
|
||||
@@ -56,9 +56,166 @@ class CaseDao(object):
|
||||
return CaseDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1})
|
||||
|
||||
@staticmethod
|
||||
def next_case_key(session, project_id):
|
||||
count_num = session.query(func.count(TestCase.id)).filter(TestCase.project_id == int(project_id)).scalar() or 0
|
||||
return 'TC-{:03d}'.format(count_num + 1)
|
||||
def next_case_key(session, project_id, module_id=None, product_id=None):
|
||||
from ..model.productModel import Product
|
||||
from ..model.projectModel import Project
|
||||
from ..model.caseModel import Module
|
||||
|
||||
product_abbr = ''
|
||||
if product_id:
|
||||
product = session.query(Product).filter(Product.id == int(product_id), Product.is_delete == 0).first()
|
||||
if product and product.name:
|
||||
product_abbr = CaseDao._generate_abbreviation(product.name)
|
||||
|
||||
project_abbr = ''
|
||||
project = session.query(Project).filter(Project.id == int(project_id), Project.is_delete == 0).first()
|
||||
if project and project.name:
|
||||
project_abbr = CaseDao._generate_abbreviation(project.name)
|
||||
|
||||
module_abbr = ''
|
||||
if module_id:
|
||||
module = session.query(Module).filter(Module.id == int(module_id), Module.is_delete == 0).first()
|
||||
if module and module.name:
|
||||
module_abbr = CaseDao._generate_abbreviation(module.name)
|
||||
|
||||
parts = ['TC']
|
||||
if product_abbr:
|
||||
parts.append(product_abbr)
|
||||
if project_abbr:
|
||||
parts.append(project_abbr)
|
||||
if module_abbr:
|
||||
parts.append(module_abbr)
|
||||
|
||||
prefix = '-'.join(parts)
|
||||
|
||||
count_num = session.query(func.count(TestCase.id)).filter(
|
||||
TestCase.project_id == int(project_id),
|
||||
TestCase.is_delete == 0,
|
||||
TestCase.case_key.like(f'{prefix}-%')
|
||||
).scalar() or 0
|
||||
|
||||
return '{}-{:03d}'.format(prefix, count_num + 1)
|
||||
|
||||
@staticmethod
|
||||
def _generate_abbreviation(name):
|
||||
import re
|
||||
chinese_pattern = re.compile(r'[\u4e00-\u9fff]+')
|
||||
english_pattern = re.compile(r'[a-zA-Z]+')
|
||||
|
||||
chinese_chars = chinese_pattern.findall(name)
|
||||
if chinese_chars:
|
||||
full_chinese = ''.join(chinese_chars)
|
||||
abbr = ''.join([CaseDao._get_pinyin_first_char(c) for c in full_chinese[:4]])
|
||||
abbr = abbr.lower()
|
||||
if len(abbr) < 2:
|
||||
abbr = abbr.ljust(2, 'n')
|
||||
return abbr
|
||||
|
||||
english_words = english_pattern.findall(name)
|
||||
if english_words:
|
||||
abbr = english_words[0].lower()[:4]
|
||||
if len(abbr) < 2:
|
||||
abbr = abbr.ljust(2, 'x')
|
||||
return abbr
|
||||
|
||||
abbr = name.lower()[:4]
|
||||
if len(abbr) < 2:
|
||||
abbr = abbr.ljust(2, 'x')
|
||||
return abbr
|
||||
|
||||
@staticmethod
|
||||
def _get_pinyin_first_char(char):
|
||||
pinyin_map = {
|
||||
'智': 'Z', '慧': 'H', '运': 'Y', '营': 'Y',
|
||||
'报': 'B', '关': 'G', '工': 'G', '作': 'Z', '台': 'T',
|
||||
'测': 'C', '试': 'S', '用': 'Y', '例': 'L',
|
||||
'产': 'C', '品': 'P', '项': 'X', '目': 'M',
|
||||
'模': 'M', '块': 'K', '管': 'G', '理': 'L',
|
||||
'系': 'X', '统': 'T', '功': 'G', '能': 'N',
|
||||
'页': 'Y', '面': 'M', '查': 'C', '询': 'X',
|
||||
'添': 'T', '加': 'J', '编': 'B', '辑': 'J',
|
||||
'删': 'S', '除': 'C', '导': 'D', '入': 'R',
|
||||
'导': 'D', '出': 'C', '批': 'P', '量': 'L',
|
||||
'设': 'S', '置': 'Z', '配': 'P', '置': 'Z',
|
||||
'权': 'Q', '限': 'X', '角': 'J', '色': 'S',
|
||||
'用': 'Y', '户': 'H', '组': 'Z', '织': 'Z',
|
||||
'计': 'J', '划': 'H', '执': 'Z', '行': 'X',
|
||||
'报': 'B', '告': 'G', '统': 'T', '计': 'J',
|
||||
'首': 'S', '页': 'Y', '仪': 'Y', '表': 'B',
|
||||
'烟': 'Y', '冒': 'M', '回': 'H', '归': 'G',
|
||||
'集': 'J', '成': 'C', '接': 'J', '口': 'K',
|
||||
'安': 'A', '全': 'Q', '日': 'R', '志': 'Z',
|
||||
'监': 'J', '控': 'K', '优': 'Y', '化': 'H',
|
||||
'性': 'X', '能': 'N', '首': 'S', '尾': 'W',
|
||||
'开': 'K', '发': 'F', '测': 'C', '运': 'Y',
|
||||
'维': 'W', '设': 'S', '计': 'J', '研': 'Y',
|
||||
'发': 'F', '部': 'B', '组': 'Z', '个': 'G',
|
||||
'人': 'R', '公': 'G', '司': 'S', '有': 'Y',
|
||||
'限': 'X', '责': 'Z', '任': 'R', '股': 'G',
|
||||
'份': 'F', '集': 'J', '团': 'T', '科': 'K',
|
||||
'技': 'J', '网': 'W', '络': 'L', '信': 'X',
|
||||
'息': 'X', '软': 'R', '件': 'J', '系': 'X',
|
||||
'统': 'T', '解': 'J', '决': 'J', '方': 'F',
|
||||
'案': 'A', '服': 'F', '务': 'W', '支': 'Z',
|
||||
'持': 'C', '培': 'P', '训': 'X', '咨': 'Z',
|
||||
'询': 'X', '销': 'X', '售': 'S', '市': 'S',
|
||||
'场': 'C', '运': 'Y', '营': 'Y', '管': 'G',
|
||||
'理': 'L', '财': 'C', '务': 'W', '人': 'R',
|
||||
'力': 'L', '资': 'Z', '源': 'Y', '行': 'X',
|
||||
'政': 'Z', '法': 'F', '律': 'L', '合': 'H',
|
||||
'规': 'G', '质': 'Z', '量': 'L', '安': 'A',
|
||||
'全': 'Q', '环': 'H', '境': 'J', '职': 'Z',
|
||||
'能': 'N', '模': 'M', '块': 'K', '页': 'Y',
|
||||
'面': 'M', '窗': 'C', '口': 'K', '表': 'B',
|
||||
'单': 'D', '按': 'A', '钮': 'N', '链': 'L',
|
||||
'接': 'J', '图': 'T', '标': 'B', '菜': 'C',
|
||||
'单': 'D', '导': 'D', '航': 'H', '搜': 'S',
|
||||
'索': 'S', '过': 'G', '滤': 'L', '排': 'P',
|
||||
'序': 'X', '分': 'F', '页': 'Y', '导': 'D',
|
||||
'入': 'R', '导': 'D', '出': 'C', '打': 'D',
|
||||
'印': 'Y', '导': 'D', '出': 'C', '删': 'S',
|
||||
'除': 'C', '复': 'F', '制': 'Z', '粘': 'N',
|
||||
'贴': 'T', '剪': 'J', '切': 'Q', '保': 'B',
|
||||
'存': 'C', '取': 'Q', '消': 'X', '确': 'Q',
|
||||
'认': 'R', '提': 'T', '交': 'J', '审': 'S',
|
||||
'核': 'H', '批': 'P', '准': 'Z', '拒': 'J',
|
||||
'绝': 'J', '返': 'F', '回': 'H', '修': 'X',
|
||||
'改': 'G', '查': 'C', '看': 'K', '详': 'X',
|
||||
'情': 'Q', '列': 'L', '表': 'B', '统': 'T',
|
||||
'计': 'J', '图': 'T', '表': 'B', '报': 'B',
|
||||
'告': 'G', '日': 'R', '志': 'Z', '备': 'B',
|
||||
'注': 'Z', '描': 'M', '述': 'S', '名': 'M',
|
||||
'称': 'C', '编': 'B', '号': 'H', '类': 'L',
|
||||
'型': 'X', '状': 'Z', '态': 'T', '优': 'Y',
|
||||
'先': 'X', '级': 'J', '标': 'B', '签': 'Q',
|
||||
'关': 'G', '键': 'J', '词': 'C', '描': 'M',
|
||||
'述': 'S', '附': 'F', '件': 'J', '链': 'L',
|
||||
'接': 'J', '时': 'S', '间': 'J', '日': 'R',
|
||||
'期': 'Q', '数': 'S', '量': 'L', '金': 'J',
|
||||
'额': 'E', '价': 'J', '格': 'G', '数': 'S',
|
||||
'据': 'J', '库': 'K', '服': 'F', '务': 'W',
|
||||
'器': 'Q', '线': 'X', '程': 'C', '进': 'J',
|
||||
'度': 'D', '速': 'S', '度': 'D', '效': 'X',
|
||||
'率': 'L', '性': 'X', '能': 'N', '稳': 'W',
|
||||
'定': 'D', '可': 'K', '靠': 'K', '安': 'A',
|
||||
'全': 'Q', '兼': 'J', '容': 'R', '扩': 'K',
|
||||
'展': 'Z', '升': 'S', '级': 'J', '更': 'G',
|
||||
'新': 'X', '修': 'X', '补': 'B', '修': 'X',
|
||||
'复': 'F', '优': 'Y', '化': 'H', '重': 'Z',
|
||||
'构': 'G', '改': 'G', '造': 'Z', '移': 'Y',
|
||||
'植': 'Z', '集': 'J', '成': 'C', '接': 'J',
|
||||
'口': 'K', '调': 'T', '试': 'S', '测': 'C',
|
||||
'试': 'S', '验': 'Y', '证': 'Z', '确': 'Q',
|
||||
'认': 'R', '回': 'H', '归': 'G', '演': 'Y',
|
||||
'示': 'S', '培': 'P', '训': 'X', '指': 'Z',
|
||||
'导': 'D', '支': 'Z', '持': 'C', '帮': 'B',
|
||||
'助': 'Z', '反': 'F', '馈': 'K', '投': 'T',
|
||||
'诉': 'S', '建': 'J', '议': 'Y', '评': 'P',
|
||||
'独': 'D', '立': 'L', '站': 'Z', '采': 'C',
|
||||
'购': 'G', '供': 'G', '应': 'Y', '商': 'S',
|
||||
'价': 'J', '满': 'M', '意': 'Y', '度': 'D'
|
||||
}
|
||||
return pinyin_map.get(char, 'N')
|
||||
|
||||
@staticmethod
|
||||
def next_snapshot_version(session, case_id):
|
||||
|
||||
@@ -183,3 +183,23 @@ class RbacDao(object):
|
||||
def get_role_name_map(session):
|
||||
items = session.query(Role).filter(Role.is_delete == 0, Role.status == 1).all()
|
||||
return {item.id: item.name for item in items}
|
||||
|
||||
@staticmethod
|
||||
def get_menu_permission_codes(session, menu_ids):
|
||||
if not menu_ids:
|
||||
return []
|
||||
items = session.query(Menu.permission_code).filter(
|
||||
Menu.id.in_(menu_ids),
|
||||
Menu.is_delete == 0
|
||||
).all()
|
||||
return [item[0] for item in items if item[0]]
|
||||
|
||||
@staticmethod
|
||||
def get_permission_ids_by_codes(session, permission_codes):
|
||||
if not permission_codes:
|
||||
return []
|
||||
items = session.query(Permission.id).filter(
|
||||
Permission.code.in_(permission_codes),
|
||||
Permission.is_delete == 0
|
||||
).all()
|
||||
return [item[0] for item in items]
|
||||
|
||||
BIN
app/api/model/__pycache__/automationModel.cpython-38.pyc
Normal file
BIN
app/api/model/__pycache__/automationModel.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
70
app/api/model/automationModel.py
Normal file
70
app/api/model/automationModel.py
Normal file
@@ -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='修改时间')
|
||||
@@ -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='修改时间')
|
||||
|
||||
BIN
app/api/service/__pycache__/automationService.cpython-38.pyc
Normal file
BIN
app/api/service/__pycache__/automationService.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
app/api/service/__pycache__/jenkinsPollService.cpython-38.pyc
Normal file
BIN
app/api/service/__pycache__/jenkinsPollService.cpython-38.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
521
app/api/service/automationService.py
Normal file
521
app/api/service/automationService.py
Normal file
@@ -0,0 +1,521 @@
|
||||
# encoding: UTF-8
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from ..dao.automationDao import AutomationDao
|
||||
from ..model.automationModel import AutoExecution, AutoExecutionCase
|
||||
from ..model.planModel import PlanCase
|
||||
from ..service.planService import PlanService
|
||||
from common.jenkinsRequest import JenkinsRequest
|
||||
from const import JENKINS_DEFAULT_JOB, PLATFORM_BASE_URL
|
||||
from logger import logger
|
||||
|
||||
class AutomationService(object):
|
||||
STATUS_PENDING = 0
|
||||
STATUS_TRIGGERING = 1
|
||||
STATUS_QUEUED = 2
|
||||
STATUS_RUNNING = 3
|
||||
STATUS_SUCCESS = 4
|
||||
STATUS_FAILED = 5
|
||||
STATUS_CANCELED = 6
|
||||
STATUS_TRIGGER_FAILED = 7
|
||||
STATUS_CALLBACK_ERROR = 8
|
||||
|
||||
CASE_STATUS_PENDING = 0
|
||||
CASE_STATUS_RUNNING = 1
|
||||
CASE_STATUS_PASSED = 2
|
||||
CASE_STATUS_FAILED = 3
|
||||
CASE_STATUS_BLOCKED = 4
|
||||
CASE_STATUS_SKIPPED = 5
|
||||
CASE_STATUS_NOT_FOUND = 6
|
||||
CASE_STATUS_CANCELED = 7
|
||||
|
||||
PLAN_CASE_STATUS_MAP = {
|
||||
CASE_STATUS_PASSED: 1,
|
||||
CASE_STATUS_FAILED: 2,
|
||||
CASE_STATUS_BLOCKED: 3,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_execution_no():
|
||||
return 'AE' + datetime.now().strftime('%Y%m%d%H%M%S%f')[:-3]
|
||||
|
||||
@staticmethod
|
||||
def generate_callback_token():
|
||||
return secrets.token_hex(16)
|
||||
|
||||
@staticmethod
|
||||
def create_case_execution(session, req_data, user_id):
|
||||
case_id = req_data.get('caseId') or req_data.get('case_id')
|
||||
env_code = req_data.get('envCode') or req_data.get('env_code')
|
||||
if not case_id or not env_code:
|
||||
return {}, 'caseId、envCode 为必传参数'
|
||||
case_item = AutomationDao.query_case_auto_item(session, case_id)
|
||||
if not case_item:
|
||||
return {}, '该用例不存在或未接入自动化'
|
||||
running_exists = AutomationService.get_running_execution_by_case(session, case_id, env_code)
|
||||
if running_exists:
|
||||
return {}, '该用例在当前环境已有执行中任务'
|
||||
callback_token = AutomationService.generate_callback_token()
|
||||
execution_obj, err_msg = AutomationDao.create_execution(session, {
|
||||
'execution_no': AutomationService.generate_execution_no(),
|
||||
'trigger_type': 1,
|
||||
'project_id': case_item.project_id,
|
||||
'source_case_id': case_item.id,
|
||||
'env_code': env_code,
|
||||
'run_mode': int(req_data.get('runMode') or req_data.get('run_mode') or 1),
|
||||
'status': AutomationService.STATUS_PENDING,
|
||||
'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': user_id,
|
||||
'trigger_message': req_data.get('remark'),
|
||||
'callback_token': callback_token,
|
||||
'ext': {}
|
||||
})
|
||||
if err_msg:
|
||||
return {}, err_msg
|
||||
_, err_msg = AutomationDao.batch_create_execution_cases(session, [{
|
||||
'execution_id': execution_obj.id,
|
||||
'case_id': case_item.id,
|
||||
'case_key': case_item.case_key,
|
||||
'case_title': case_item.title,
|
||||
'run_order': 1,
|
||||
'status': AutomationService.CASE_STATUS_PENDING
|
||||
}])
|
||||
if err_msg:
|
||||
return {}, err_msg
|
||||
trigger_ok, trigger_msg = AutomationService.trigger_jenkins(session, execution_obj.id, req_data.get('jenkinsJobName'))
|
||||
if not trigger_ok:
|
||||
return {}, trigger_msg
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_obj.id)
|
||||
return execution.to_dict() if execution else {'id': execution_obj.id}, ''
|
||||
|
||||
@staticmethod
|
||||
def create_plan_execution(session, req_data, user_id):
|
||||
from logger import logger
|
||||
|
||||
plan_id = req_data.get('planId') or req_data.get('plan_id')
|
||||
env_code = req_data.get('envCode') or req_data.get('env_code')
|
||||
round_no = req_data.get('roundNo') or req_data.get('round_no')
|
||||
case_ids = req_data.get('caseIds') or req_data.get('case_ids') or []
|
||||
|
||||
logger.info(f'====== 计划自动化执行开始 ======')
|
||||
logger.info(f'请求参数: plan_id={plan_id}, env_code={env_code}, round_no={round_no}, case_ids={case_ids}, user_id={user_id}')
|
||||
|
||||
if not plan_id or not env_code:
|
||||
logger.error('参数校验失败: planId、envCode 为必传参数')
|
||||
return {}, 'planId、envCode 为必传参数'
|
||||
|
||||
running_exists = AutomationService.get_running_execution_by_plan(session, plan_id, env_code)
|
||||
if running_exists:
|
||||
logger.error(f'计划执行冲突: 计划 {plan_id} 在环境 {env_code} 已有执行中任务')
|
||||
return {}, '该计划在当前环境已有执行中任务'
|
||||
|
||||
logger.info(f'查询计划自动化用例: plan_id={plan_id}, round_no={round_no}')
|
||||
items = AutomationDao.query_plan_auto_cases(session, plan_id, round_no, case_ids)
|
||||
if not items:
|
||||
logger.error('计划下无可执行自动化用例')
|
||||
return {}, '计划下无可执行自动化用例'
|
||||
|
||||
logger.info(f'查询到 {len(items)} 条自动化用例')
|
||||
for idx, (plan_case, case_item) in enumerate(items, start=1):
|
||||
logger.info(f' {idx}. case_key={case_item.case_key}, case_title={case_item.title}')
|
||||
|
||||
project_id = items[0][1].project_id
|
||||
callback_token = AutomationService.generate_callback_token()
|
||||
execution_no = AutomationService.generate_execution_no()
|
||||
|
||||
logger.info(f'创建执行记录: execution_no={execution_no}, project_id={project_id}, plan_id={plan_id}')
|
||||
execution_obj, err_msg = AutomationDao.create_execution(session, {
|
||||
'execution_no': execution_no,
|
||||
'trigger_type': 2,
|
||||
'project_id': project_id,
|
||||
'plan_id': int(plan_id),
|
||||
'plan_round_no': int(round_no) if round_no not in (None, '') else None,
|
||||
'env_code': env_code,
|
||||
'run_mode': int(req_data.get('runMode') or req_data.get('run_mode') or 1),
|
||||
'status': AutomationService.STATUS_PENDING,
|
||||
'total_count': len(items),
|
||||
'pending_count': len(items),
|
||||
'running_count': 0,
|
||||
'passed_count': 0,
|
||||
'failed_count': 0,
|
||||
'blocked_count': 0,
|
||||
'skipped_count': 0,
|
||||
'not_found_count': 0,
|
||||
'trigger_by': user_id,
|
||||
'trigger_message': req_data.get('remark'),
|
||||
'callback_token': callback_token,
|
||||
'ext': {}
|
||||
})
|
||||
if err_msg:
|
||||
logger.error(f'创建执行记录失败: {err_msg}')
|
||||
return {}, err_msg
|
||||
|
||||
logger.info(f'执行记录创建成功: execution_id={execution_obj.id}')
|
||||
|
||||
batch_list = []
|
||||
for idx, (plan_case, case_item) in enumerate(items, start=1):
|
||||
batch_list.append({
|
||||
'execution_id': execution_obj.id,
|
||||
'plan_case_id': plan_case.id,
|
||||
'case_id': case_item.id,
|
||||
'case_key': case_item.case_key,
|
||||
'case_title': case_item.title,
|
||||
'run_order': idx,
|
||||
'status': AutomationService.CASE_STATUS_PENDING
|
||||
})
|
||||
|
||||
logger.info(f'批量创建执行明细: {len(batch_list)} 条')
|
||||
_, err_msg = AutomationDao.batch_create_execution_cases(session, batch_list)
|
||||
if err_msg:
|
||||
logger.error(f'批量创建执行明细失败: {err_msg}')
|
||||
return {}, err_msg
|
||||
|
||||
logger.info(f'触发Jenkins构建: execution_id={execution_obj.id}')
|
||||
trigger_ok, trigger_msg = AutomationService.trigger_jenkins(session, execution_obj.id, req_data.get('jenkinsJobName'))
|
||||
if not trigger_ok:
|
||||
logger.error(f'Jenkins触发失败: {trigger_msg}')
|
||||
return {}, trigger_msg
|
||||
|
||||
logger.info('计划自动化执行成功')
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_obj.id)
|
||||
logger.info(f'====== 计划自动化执行结束 ======')
|
||||
return execution.to_dict() if execution else {'id': execution_obj.id}, ''
|
||||
|
||||
@staticmethod
|
||||
def trigger_jenkins(session, execution_id, job_name=None):
|
||||
|
||||
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return False, '未查询到对应执行记录'
|
||||
AutomationDao.update_execution_by_id(session, execution_id, {'status': AutomationService.STATUS_TRIGGERING})
|
||||
|
||||
cases, _ = AutomationDao.list_execution_case_by_filters(session, [AutoExecutionCase.execution_id == int(execution_id)], 1, 100000)
|
||||
case_keys = [case.case_key for case in cases if case.case_key]
|
||||
test_target = ','.join(case_keys)
|
||||
|
||||
test_type = 'story' if case_keys else 'all'
|
||||
|
||||
params = {
|
||||
'EXECUTION_ID': execution.id,
|
||||
'CALLBACK_TOKEN': execution.callback_token,
|
||||
'PLATFORM_BASE_URL': PLATFORM_BASE_URL,
|
||||
'ENV_CODE': execution.env_code,
|
||||
'RUN_MODE': execution.run_mode,
|
||||
'TRIGGER_TYPE': execution.trigger_type,
|
||||
'TEST_TYPE': test_type,
|
||||
'TEST_TARGET': test_target
|
||||
}
|
||||
|
||||
jenkins_url = None
|
||||
jenkins_job_name = None
|
||||
if execution.plan_id:
|
||||
plan = AutomationDao.get_plan_by_id(session, execution.plan_id)
|
||||
if plan and plan.jenkins_url:
|
||||
jenkins_url = plan.jenkins_url
|
||||
if '/job/' in jenkins_url:
|
||||
import re
|
||||
match = re.match(r'^(https?://[^/]+)/(job/[^/]+)/?.*$', jenkins_url)
|
||||
if match:
|
||||
jenkins_url = match.group(1)
|
||||
jenkins_job_name = match.group(2).replace('job/', '')
|
||||
logger.info(f'从计划配置中解析 Jenkins: base_url={jenkins_url}, job_name={jenkins_job_name}')
|
||||
|
||||
jenkins_request = JenkinsRequest(jenkins_url=jenkins_url)
|
||||
target_job_name = jenkins_job_name or job_name or execution.jenkins_job_name or JENKINS_DEFAULT_JOB
|
||||
success, err_msg, payload = jenkins_request.build_with_parameters(params, target_job_name)
|
||||
if not success:
|
||||
AutomationDao.update_execution_by_id(session, execution_id, {
|
||||
'status': AutomationService.STATUS_TRIGGER_FAILED,
|
||||
'trigger_message': err_msg
|
||||
})
|
||||
return False, err_msg
|
||||
update_info = {
|
||||
'status': AutomationService.STATUS_QUEUED,
|
||||
'jenkins_job_name': payload.get('job_name') or job_name or JENKINS_DEFAULT_JOB,
|
||||
'jenkins_queue_id': payload.get('queue_id')
|
||||
}
|
||||
if payload.get('location'):
|
||||
update_info['trigger_message'] = payload.get('location')
|
||||
AutomationDao.update_execution_by_id(session, execution_id, update_info)
|
||||
return True, ''
|
||||
|
||||
@staticmethod
|
||||
def get_running_execution_by_case(session, case_id, env_code):
|
||||
items, _ = AutomationDao.list_execution_by_filters(session, [
|
||||
AutoExecution.source_case_id == int(case_id),
|
||||
AutoExecution.env_code == env_code,
|
||||
AutoExecution.status.in_([0, 1, 2, 3])
|
||||
], 1, 1)
|
||||
return items[0] if items else None
|
||||
|
||||
@staticmethod
|
||||
def get_running_execution_by_plan(session, plan_id, env_code):
|
||||
items, _ = AutomationDao.list_execution_by_filters(session, [
|
||||
AutoExecution.plan_id == int(plan_id),
|
||||
AutoExecution.env_code == env_code,
|
||||
AutoExecution.status.in_([0, 1, 2, 3])
|
||||
], 1, 1)
|
||||
return items[0] if items else None
|
||||
|
||||
@staticmethod
|
||||
def list_executions(session, req_data):
|
||||
filters = []
|
||||
project_id = req_data.get('projectId') or req_data.get('project_id')
|
||||
plan_id = req_data.get('planId') or req_data.get('plan_id')
|
||||
status = req_data.get('status')
|
||||
trigger_type = req_data.get('triggerType') or req_data.get('trigger_type')
|
||||
if project_id:
|
||||
filters.append(AutoExecution.project_id == int(project_id))
|
||||
if plan_id:
|
||||
filters.append(AutoExecution.plan_id == int(plan_id))
|
||||
if status not in (None, ''):
|
||||
filters.append(AutoExecution.status == int(status))
|
||||
if trigger_type not in (None, ''):
|
||||
filters.append(AutoExecution.trigger_type == int(trigger_type))
|
||||
items, total = AutomationDao.list_execution_by_filters(session, filters, req_data.get('pageNo') or req_data.get('page') or 1, req_data.get('pageSize') or req_data.get('size') or 20)
|
||||
return {'list': [item.to_dict() for item in items], 'total': total}
|
||||
|
||||
@staticmethod
|
||||
def get_execution_detail(session, execution_id):
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return {}, '未查询到对应执行记录'
|
||||
ret = execution.to_dict()
|
||||
summary = AutomationDao.count_execution_case_summary(session, execution_id)
|
||||
ret.update({
|
||||
'summary': {
|
||||
'total': summary.get('total', 0),
|
||||
'pending': summary.get(0, 0),
|
||||
'running': summary.get(1, 0),
|
||||
'passed': summary.get(2, 0),
|
||||
'failed': summary.get(3, 0),
|
||||
'blocked': summary.get(4, 0),
|
||||
'skipped': summary.get(5, 0),
|
||||
'notFound': summary.get(6, 0),
|
||||
'canceled': summary.get(7, 0)
|
||||
}
|
||||
})
|
||||
return ret, ''
|
||||
|
||||
@staticmethod
|
||||
def list_execution_cases(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
if not execution_id:
|
||||
return {}, 'executionId 为必传参数'
|
||||
filters = [AutoExecutionCase.execution_id == int(execution_id)]
|
||||
status = req_data.get('status')
|
||||
if status not in (None, ''):
|
||||
filters.append(AutoExecutionCase.status == int(status))
|
||||
items, total = AutomationDao.list_execution_case_by_filters(session, filters, req_data.get('pageNo') or req_data.get('page') or 1, req_data.get('pageSize') or req_data.get('size') or 20)
|
||||
return {'list': [item.to_dict() for item in items], 'total': total}, ''
|
||||
|
||||
@staticmethod
|
||||
def pull_execution_cases(session, execution_id, callback_token):
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return {}, '未查询到对应执行记录'
|
||||
if execution.callback_token != callback_token:
|
||||
return {}, '回调鉴权失败'
|
||||
case_items, _ = AutomationDao.list_execution_case_by_filters(session, [AutoExecutionCase.execution_id == int(execution_id)], 1, 100000)
|
||||
return {
|
||||
'executionId': execution.id,
|
||||
'executionNo': execution.execution_no,
|
||||
'triggerType': execution.trigger_type,
|
||||
'projectId': execution.project_id,
|
||||
'planId': execution.plan_id,
|
||||
'envCode': execution.env_code,
|
||||
'runMode': execution.run_mode,
|
||||
'items': [{
|
||||
'executionCaseId': item.id,
|
||||
'planCaseId': item.plan_case_id,
|
||||
'caseId': item.case_id,
|
||||
'caseKey': item.case_key,
|
||||
'caseTitle': item.case_title,
|
||||
'runOrder': item.run_order
|
||||
} for item in case_items]
|
||||
}, ''
|
||||
|
||||
@staticmethod
|
||||
def mark_execution_queued(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return 0, '未查询到对应执行记录'
|
||||
return AutomationDao.update_execution_by_id(session, execution_id, {
|
||||
'status': AutomationService.STATUS_QUEUED,
|
||||
'jenkins_queue_id': req_data.get('queueId') or req_data.get('queue_id'),
|
||||
'jenkins_job_name': req_data.get('jobName') or req_data.get('job_name') or execution.jenkins_job_name,
|
||||
'jenkins_build_number': req_data.get('buildNumber') or req_data.get('build_number'),
|
||||
'jenkins_build_url': req_data.get('buildUrl') or req_data.get('build_url')
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def mark_execution_started(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return 0, '未查询到对应执行记录'
|
||||
start_time = req_data.get('startTime') or req_data.get('start_time') or datetime.now()
|
||||
return AutomationDao.update_execution_by_id(session, execution_id, {
|
||||
'status': AutomationService.STATUS_RUNNING,
|
||||
'jenkins_job_name': req_data.get('jobName') or req_data.get('job_name') or execution.jenkins_job_name,
|
||||
'jenkins_build_number': req_data.get('buildNumber') or req_data.get('build_number'),
|
||||
'jenkins_build_url': req_data.get('buildUrl') or req_data.get('build_url'),
|
||||
'console_url': req_data.get('consoleUrl') or req_data.get('console_url'),
|
||||
'start_time': start_time
|
||||
})
|
||||
|
||||
@staticmethod
|
||||
def save_case_result(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
execution_case_id = req_data.get('executionCaseId') or req_data.get('execution_case_id')
|
||||
case_id = req_data.get('caseId') or req_data.get('case_id')
|
||||
if not execution_id or (not execution_case_id and not case_id):
|
||||
return 0, 'executionId、executionCaseId/caseId 为必传参数'
|
||||
execution_case = AutomationDao.get_execution_case_by_id(session, execution_case_id) if execution_case_id else None
|
||||
if not execution_case and case_id:
|
||||
execution_case = AutomationDao.get_execution_case_by_unique(session, execution_id, case_id, req_data.get('planCaseId') or req_data.get('plan_case_id'))
|
||||
if not execution_case:
|
||||
return 0, '未查询到对应执行明细'
|
||||
update_info = {
|
||||
'status': int(req_data.get('status')) if req_data.get('status') is not None else execution_case.status,
|
||||
'pytest_nodeid': req_data.get('pytestNodeid') or req_data.get('pytest_nodeid'),
|
||||
'result_message': req_data.get('resultMessage') or req_data.get('result_message'),
|
||||
'error_message': req_data.get('errorMessage') or req_data.get('error_message'),
|
||||
'stack_trace': req_data.get('stackTrace') or req_data.get('stack_trace'),
|
||||
'report_url': req_data.get('reportUrl') or req_data.get('report_url'),
|
||||
'duration_seconds': req_data.get('durationSeconds') or req_data.get('duration_seconds'),
|
||||
'started_time': req_data.get('startedTime') or req_data.get('started_time') or execution_case.started_time,
|
||||
'finished_time': req_data.get('finishedTime') or req_data.get('finished_time') or datetime.now(),
|
||||
'ext': req_data.get('ext') if req_data.get('ext') is not None else execution_case.ext
|
||||
}
|
||||
update_id, err_msg = AutomationDao.update_execution_case_by_id(session, execution_case.id, update_info)
|
||||
if err_msg:
|
||||
return update_id, err_msg
|
||||
execution_case = AutomationDao.get_execution_case_by_id(session, execution_case.id)
|
||||
if execution_case and execution_case.plan_case_id:
|
||||
AutomationService.sync_plan_case_result(session, execution_case)
|
||||
AutomationService.refresh_execution_summary(session, execution_id)
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if execution and execution.plan_id:
|
||||
AutomationService.refresh_plan_status(session, execution.plan_id)
|
||||
return execution_case.id, ''
|
||||
|
||||
@staticmethod
|
||||
def finish_execution(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return 0, '未查询到对应执行记录'
|
||||
end_time = req_data.get('endTime') or req_data.get('end_time') or datetime.now()
|
||||
start_time = req_data.get('startTime') or req_data.get('start_time') or execution.start_time
|
||||
update_info = {
|
||||
'jenkins_build_number': req_data.get('buildNumber') or req_data.get('build_number') or execution.jenkins_build_number,
|
||||
'jenkins_build_url': req_data.get('buildUrl') or req_data.get('build_url') or execution.jenkins_build_url,
|
||||
'console_url': req_data.get('consoleUrl') or req_data.get('console_url') or execution.console_url,
|
||||
'report_url': req_data.get('reportUrl') or req_data.get('report_url') or execution.report_url,
|
||||
'start_time': start_time,
|
||||
'end_time': end_time,
|
||||
'duration_seconds': req_data.get('durationSeconds') or req_data.get('duration_seconds') or AutomationService.calc_duration_seconds(start_time, end_time)
|
||||
}
|
||||
update_id, err_msg = AutomationDao.update_execution_by_id(session, execution_id, update_info)
|
||||
if err_msg:
|
||||
return update_id, err_msg
|
||||
AutomationService.refresh_execution_summary(session, execution_id, force_finish=True)
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if execution and execution.plan_id:
|
||||
AutomationService.refresh_plan_status(session, execution.plan_id)
|
||||
return int(execution_id), ''
|
||||
|
||||
@staticmethod
|
||||
def abort_execution(session, req_data):
|
||||
execution_id = req_data.get('executionId') or req_data.get('execution_id')
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return 0, '未查询到对应执行记录'
|
||||
case_items, _ = AutomationDao.list_execution_case_by_filters(session, [AutoExecutionCase.execution_id == int(execution_id), AutoExecutionCase.status.in_([0, 1])], 1, 100000)
|
||||
for item in case_items:
|
||||
AutomationDao.update_execution_case_by_id(session, item.id, {'status': AutomationService.CASE_STATUS_CANCELED, 'finished_time': datetime.now()})
|
||||
update_id, err_msg = AutomationDao.update_execution_by_id(session, execution_id, {
|
||||
'status': int(req_data.get('status') or AutomationService.STATUS_CANCELED),
|
||||
'trigger_message': req_data.get('message') or req_data.get('trigger_message'),
|
||||
'jenkins_build_number': req_data.get('buildNumber') or req_data.get('build_number') or execution.jenkins_build_number,
|
||||
'console_url': req_data.get('consoleUrl') or req_data.get('console_url') or execution.console_url,
|
||||
'end_time': datetime.now()
|
||||
})
|
||||
if err_msg:
|
||||
return update_id, err_msg
|
||||
AutomationService.refresh_execution_summary(session, execution_id, keep_terminal_status=True)
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if execution and execution.plan_id:
|
||||
AutomationService.refresh_plan_status(session, execution.plan_id)
|
||||
return int(execution_id), ''
|
||||
|
||||
@staticmethod
|
||||
def sync_plan_case_result(session, execution_case):
|
||||
status = AutomationService.PLAN_CASE_STATUS_MAP.get(execution_case.status)
|
||||
update_info = {
|
||||
'actual_result': execution_case.error_message or execution_case.result_message,
|
||||
'executed_time': execution_case.finished_time or datetime.now(),
|
||||
'execution_duration': execution_case.duration_seconds
|
||||
}
|
||||
if status is not None:
|
||||
update_info['status'] = status
|
||||
AutomationDao.update_plan_case_result(session, execution_case.plan_case_id, update_info)
|
||||
|
||||
@staticmethod
|
||||
def refresh_execution_summary(session, execution_id, force_finish=False, keep_terminal_status=False):
|
||||
summary = AutomationDao.count_execution_case_summary(session, execution_id)
|
||||
execution = AutomationDao.get_execution_by_id(session, execution_id)
|
||||
if not execution:
|
||||
return
|
||||
update_info = {
|
||||
'total_count': summary.get('total', 0),
|
||||
'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)
|
||||
}
|
||||
total = summary.get('total', 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 not keep_terminal_status:
|
||||
if running_count > 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())
|
||||
@@ -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):
|
||||
|
||||
302
app/api/service/jenkinsPollService.py
Normal file
302
app/api/service/jenkinsPollService.py
Normal file
@@ -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)
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
233
app/api/views.py
233
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')
|
||||
|
||||
59
automation_menus.sql
Normal file
59
automation_menus.sql
Normal file
@@ -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;
|
||||
96
automation_permission.sql
Normal file
96
automation_permission.sql
Normal file
@@ -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 |
|
||||
@@ -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 | 查看统计 |
|
||||
|
||||
36
check_permission_table.py
Normal file
36
check_permission_table.py
Normal file
@@ -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()
|
||||
BIN
common/__pycache__/jenkinsRequest.cpython-38.pyc
Normal file
BIN
common/__pycache__/jenkinsRequest.cpython-38.pyc
Normal file
Binary file not shown.
82
common/jenkinsRequest.py
Normal file
82
common/jenkinsRequest.py
Normal file
@@ -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), {}
|
||||
14
const.py
14
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')
|
||||
|
||||
75
generate_automation_menus.py
Normal file
75
generate_automation_menus.py
Normal file
@@ -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()
|
||||
592
resources/automation_api_doc.md
Normal file
592
resources/automation_api_doc.md
Normal file
@@ -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 回调后更新。
|
||||
|
||||
177
resources/sql/automation_execution_pgsql.sql
Normal file
177
resources/sql/automation_execution_pgsql.sql
Normal file
@@ -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;
|
||||
47
test_fix.py
Normal file
47
test_fix.py
Normal file
@@ -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权限(已成功删除)')
|
||||
534
数据库语句
534
数据库语句
@@ -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();
|
||||
Reference in New Issue
Block a user