提交所有代码到 qiaoxinjiu 分支

This commit is contained in:
qiaoxinjiu
2026-05-11 14:29:16 +08:00
parent 01a4ac8ea1
commit 2fea5adb44
59 changed files with 4957 additions and 1603 deletions

View File

@@ -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. 初始化权限菜单角色的更完整种子数据

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -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 接口的稳定性、可靠性和安全性,为前端应用提供可靠的后端支持。

View File

@@ -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.

View 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)

View File

@@ -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:

View File

@@ -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):
"""查询计划进度统计。"""

View File

@@ -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)

View File

@@ -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, ''

Binary file not shown.

View 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()

View File

@@ -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):

View File

@@ -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]

View 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='修改时间')

View File

@@ -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='修改时间')

View 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())

View File

@@ -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):

View 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)

View File

@@ -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})

View File

@@ -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)

View File

@@ -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

View File

@@ -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
View 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
View 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 |

View File

@@ -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
View 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()

Binary file not shown.

82
common/jenkinsRequest.py Normal file
View 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), {}

View File

@@ -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')

View 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()

View 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 回调后更新。

View 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
View 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权限已成功删除')

View File

@@ -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 '父模块ID0表示根节点';
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();