diff --git a/.agents/RBAC_API.md b/.agents/RBAC_API.md new file mode 100644 index 0000000..74e1550 --- /dev/null +++ b/.agents/RBAC_API.md @@ -0,0 +1,807 @@ +# RBAC / 用户 / 菜单管理接口文档 + +本文档基于当前已落地代码整理,适合直接给前端联调使用。 + +## 1. 通用说明 + +### 1.1 响应结构 + +成功: + +```json +{ + "success": true, + "code": 20000, + "message": "", + "data": {} +} +``` + +失败: + +```json +{ + "success": false, + "code": 40009, + "message": "具体错误信息", + "data": null +} +``` + +### 1.2 错误码使用习惯 + +| code | 说明 | +|---|---| +| 20000 | 成功 | +| 40009 | 创建类失败 / 参数校验失败 | +| 40011 | 详情查询失败 | +| 40012 | 更新 / 删除 / 分配失败 | + +### 1.3 当前实现注意事项 + +- 用户密码字段当前直接写入 `password_hash`,还未做真正加密 +- 分配类接口均为覆盖式保存 +- 当前密码字段是占位实现,后续建议替换为真实 hash + +--- + +## 2. 角色管理 + +### 2.1 角色列表 +- 方法:`GET` +- 路径:`/role/list` + +请求参数: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| keyword | string | 否 | 角色名称模糊搜索 | +| status | int | 否 | 1启用 0禁用 | +| pageNo | int | 否 | 页码,默认1 | +| pageSize | int | 否 | 每页条数,默认20 | + +返回 `data`: + +```json +{ + "list": [ + { + "id": 1, + "code": "admin", + "name": "超级管理员", + "description": "系统内置超级管理员", + "status": 1, + "is_system": 1, + "created_by": 1, + "created_time": "2025-01-01 10:00:00", + "updated_time": "2025-01-01 10:00:00" + } + ], + "total": 1 +} +``` + +### 2.2 角色详情 +- 方法:`GET` +- 路径:`/role/detail` + +请求参数: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| roleId | int | 是 | 角色ID | + +返回:单个角色对象。 + +### 2.3 创建角色 +- 方法:`POST` +- 路径:`/role/create` + +请求体: + +```json +{ + "code": "test_manager", + "name": "测试经理", + "description": "测试经理角色", + "status": 1, + "isSystem": 0, + "createdBy": 1 +} +``` + +返回: + +```json +{ + "id": 2 +} +``` + +### 2.4 更新角色 +- 方法:`POST` +- 路径:`/role/update` + +请求体: + +```json +{ + "roleId": 2, + "name": "高级测试经理", + "description": "升级后的测试经理角色" +} +``` + +返回: + +```json +{ + "id": 2 +} +``` + +### 2.5 删除角色 +- 方法:`POST` +- 路径:`/role/delete` + +请求体: + +```json +{ + "roleId": 2 +} +``` + +返回: + +```json +{ + "id": 2 +} +``` + +--- + +## 3. 权限管理 + +### 3.1 权限列表 +- 方法:`GET` +- 路径:`/permission/list` + +请求参数: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| keyword | string | 否 | 权限名称模糊搜索 | +| module | string | 否 | 模块名 | +| status | int | 否 | 状态 | +| pageNo | int | 否 | 页码 | +| pageSize | int | 否 | 每页条数 | + +返回 `data`: + +```json +{ + "list": [ + { + "id": 1, + "code": "user:create", + "name": "创建用户", + "module": "user", + "action": "create", + "description": "创建用户权限", + "status": 1, + "created_time": "2025-01-01 10:00:00", + "updated_time": "2025-01-01 10:00:00" + } + ], + "total": 1 +} +``` + +### 3.2 权限详情 +- 方法:`GET` +- 路径:`/permission/detail` +- 参数:`permissionId` + +### 3.3 创建权限 +- 方法:`POST` +- 路径:`/permission/create` + +请求体: + +```json +{ + "code": "user:create", + "name": "创建用户", + "module": "user", + "action": "create", + "description": "创建用户权限", + "status": 1 +} +``` + +### 3.4 更新权限 +- 方法:`POST` +- 路径:`/permission/update` + +### 3.5 删除权限 +- 方法:`POST` +- 路径:`/permission/delete` + +--- + +## 4. 菜单管理 + +### 4.1 菜单树 +- 方法:`GET` +- 路径:`/menu/tree` + +请求参数: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| status | int | 否 | 状态过滤 | + +返回 `data`: + +```json +[ + { + "id": 1, + "parent_id": 0, + "name": "系统管理", + "code": "system", + "type": 1, + "path": "/system", + "component": "Layout", + "icon": "setting", + "permission_code": null, + "sort": 1, + "visible": 1, + "status": 1, + "created_time": "2025-01-01 10:00:00", + "updated_time": "2025-01-01 10:00:00", + "children": [ + { + "id": 2, + "parent_id": 1, + "name": "用户管理", + "code": "user_manage", + "type": 2, + "path": "/system/user", + "component": "system/user/index", + "icon": "user", + "permission_code": "user:list", + "sort": 1, + "visible": 1, + "status": 1, + "children": [] + } + ] + } +] +``` + +### 4.2 菜单详情 +- 方法:`GET` +- 路径:`/menu/detail` +- 参数:`menuId` + +### 4.3 创建菜单 +- 方法:`POST` +- 路径:`/menu/create` + +请求体: + +```json +{ + "parentId": 1, + "name": "角色管理", + "code": "role_manage", + "type": 2, + "path": "/system/role", + "component": "system/role/index", + "icon": "peoples", + "permissionCode": "role:list", + "sort": 2, + "visible": 1, + "status": 1 +} +``` + +### 4.4 更新菜单 +- 方法:`POST` +- 路径:`/menu/update` + +### 4.5 删除菜单 +- 方法:`POST` +- 路径:`/menu/delete` + +--- + +## 5. 角色权限分配 + +### 5.1 查询角色权限 +- 方法:`GET` +- 路径:`/role/permission/list` +- 参数:`roleId` + +返回: + +```json +{ + "permissionIds": [1, 2, 3] +} +``` + +### 5.2 分配角色权限 +- 方法:`POST` +- 路径:`/role/permission/assign` + +请求体: + +```json +{ + "roleId": 2, + "permissionIds": [1, 2, 3, 4] +} +``` + +返回: + +```json +{ + "id": 2 +} +``` + +--- + +## 6. 角色菜单分配 + +### 6.1 查询角色菜单 +- 方法:`GET` +- 路径:`/role/menu/list` +- 参数:`roleId` + +返回: + +```json +{ + "menuIds": [1, 2, 3, 4] +} +``` + +### 6.2 分配角色菜单 +- 方法:`POST` +- 路径:`/role/menu/assign` + +请求体: + +```json +{ + "roleId": 2, + "menuIds": [1, 2, 10, 11] +} +``` + +返回: + +```json +{ + "id": 2 +} +``` + +--- + +## 7. 用户管理 + +### 7.1 用户列表 +- 方法:`GET` +- 路径:`/user/list` + +请求参数: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| keyword | string | 否 | 用户名模糊搜索 | +| status | int | 否 | 状态 | +| pageNo | int | 否 | 页码 | +| pageSize | int | 否 | 每页条数 | + +返回 `data`: + +```json +{ + "list": [ + { + "id": 1, + "username": "admin", + "real_name": "管理员", + "mobile": "13800000000", + "email": "admin@test.com", + "avatar": "", + "status": 1, + "last_login_time": "2025-01-01 10:00:00", + "created_by": 1, + "created_time": "2025-01-01 10:00:00", + "updated_time": "2025-01-01 10:00:00", + "role_ids": [1, 2], + "role_names": ["管理员", "测试经理"] + } + ], + "total": 1 +} +``` + +### 7.2 用户详情 +- 方法:`GET` +- 路径:`/user/detail` +- 参数:`userId` + +返回会额外包含: + +```json +{ + "role_ids": [1, 2] +} +``` + +### 7.3 创建用户 +- 方法:`POST` +- 路径:`/user/create` + +请求体: + +```json +{ + "username": "zhangsan", + "password": "123456", + "realName": "张三", + "mobile": "13800001111", + "email": "zhangsan@test.com", + "avatar": "", + "status": 1, + "createdBy": 1 +} +``` + +返回: + +```json +{ + "id": 3 +} +``` + +### 7.4 更新用户 +- 方法:`POST` +- 路径:`/user/update` + +### 7.5 删除用户 +- 方法:`POST` +- 路径:`/user/delete` + +--- + +## 8. 用户角色分配 + +### 8.1 查询用户角色 +- 方法:`GET` +- 路径:`/user/role/list` +- 参数:`userId` + +返回: + +```json +{ + "roleIds": [1, 2] +} +``` + +### 8.2 分配用户角色 +- 方法:`POST` +- 路径:`/user/role/assign` + +请求体: + +```json +{ + "userId": 10, + "roleIds": [2, 3] +} +``` + +响应: + +```json +{ + "id": 10 +} +``` + +--- + +## 9. 认证接口 + +### 9.1 注册 +- 方法:`POST` +- 路径:`/auth/register` + +请求体: + +```json +{ + "username": "zhangsan", + "password": "123456", + "realName": "张三", + "mobile": "13800001111", + "email": "zhangsan@test.com", + "avatar": "", + "createdBy": 1 +} +``` + +请求参数说明: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| username | string | 是 | 登录用户名 | +| password | string | 是 | 登录密码,当前直接写入 `password_hash` | +| realName | string | 否 | 真实姓名 | +| mobile | string | 否 | 手机号 | +| email | string | 否 | 邮箱 | +| avatar | string | 否 | 头像 | +| createdBy | int | 否 | 创建人 | + +成功返回: + +```json +{ + "id": 11 +} +``` + +失败场景: +- `username、password 为必传参数` +- `用户名已存在!` + +### 9.2 登录 +- 方法:`POST` +- 路径:`/auth/login` + +请求体: + +```json +{ + "username": "zhangsan", + "password": "123456" +} +``` + +请求参数说明: + +| 参数名 | 类型 | 必填 | 说明 | +|---|---|---|---| +| username | string | 是 | 登录用户名 | +| password | string | 是 | 登录密码 | + +成功返回 `data`: + +```json +{ + "id": 11, + "username": "zhangsan", + "real_name": "张三", + "mobile": "13800001111", + "email": "zhangsan@test.com", + "avatar": "", + "status": 1, + "last_login_time": null, + "created_by": 1, + "created_time": "2025-01-01 10:00:00", + "updated_time": "2025-01-01 10:00:00", + "role_ids": [2, 3] +} +``` + +失败场景: +- `username、password 为必传参数` +- `用户名或密码错误!` +- `用户已禁用!` + +登录成功额外返回: + +| 字段 | 类型 | 说明 | +|---|---|---| +| 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小时) +- 刷新机制:访问任意需要登录的接口时,如果 token 剩余有效期小于 `1800` 秒,则自动续期到完整 2 小时 +- 请求头支持: + - `accessToken` + - `accesstoken` + - `Authorization: Bearer ` + +> 当前登录接口已返回 token、过期时间和刷新机制说明。 + +--- + +## 10. 一组联调示例 + +### 9.1 创建角色 + +```http +POST /role/create +Content-Type: application/json +``` + +```json +{ + "code": "tester", + "name": "测试人员", + "description": "普通测试角色", + "status": 1, + "isSystem": 0 +} +``` + +### 9.2 创建权限 + +```json +{ + "code": "case:list", + "name": "查看用例列表", + "module": "case", + "action": "list", + "description": "查看测试用例列表", + "status": 1 +} +``` + +### 9.3 创建菜单 + +```json +{ + "parentId": 1, + "name": "权限管理", + "code": "permission_manage", + "type": 2, + "path": "/system/permission", + "component": "system/permission/index", + "icon": "lock", + "permissionCode": "permission:list", + "sort": 3, + "visible": 1, + "status": 1 +} +``` + +### 9.4 给角色分配权限 + +```json +{ + "roleId": 5, + "permissionIds": [1, 2, 3, 4] +} +``` + +### 9.5 给角色分配菜单 + +```json +{ + "roleId": 5, + "menuIds": [1, 2, 8, 9] +} +``` + +### 9.6 创建用户 + +```json +{ + "username": "lisi", + "password": "123456", + "realName": "李四", + "mobile": "13800002222", + "email": "lisi@test.com", + "status": 1 +} +``` + +### 10.7 给用户分配角色 + +```json +{ + "userId": 10, + "roleIds": [5] +} +``` + +### 10.8 注册 + +```json +{ + "username": "new_user", + "password": "123456", + "realName": "新用户", + "mobile": "13800009999", + "email": "new_user@test.com" +} +``` + +### 10.9 登录 + +```json +{ + "username": "new_user", + "password": "123456" +} +``` + +### 10.10 鉴权说明 + +请求受保护接口时,请在请求头中携带以下任意一种: + +```text +accessToken: +``` + +或 + +```text +accesstoken: +``` + +或 + +```text +Authorization: Bearer +``` + +当前机制: +- token 存 Redis +- 默认有效期:2 小时 +- 剩余有效期小于 30 分钟时,访问受保护接口会自动续期 +- 注册、登录接口不需要 token +- 其他接口已逐步接入登录鉴权与权限限制 + +--- + +## 11. 当前初始化 SQL 已包含的业务菜单 + +已补入以下可直接录入的菜单数据: + +### 系统管理 +- `system` 系统管理 +- `role_manage` 角色管理 +- `user_manage` 用户管理 +- `permission_manage` 权限管理 +- `menu_manage` 菜单管理 + +### 测试平台 +- `test_platform` 测试平台 +- `product_manage` 产品管理 +- `project_manage` 项目管理 +- `case_manage` 用例管理 +- `plan_manage` 测试计划 +- `report_manage` 测试报告 + +### 造数工具 +- `data_tools` 造数工具 +- `data_builder_manage` 数据库造数 +- `data_factory_manage` 造数工厂 + +如果后续你要,我可以继续补: + +1. Swagger/OpenAPI 版本 +2. Apifox / Postman 导入版 +3. 初始化权限菜单角色的更完整种子数据 diff --git a/.agents/rbac_init.sql b/.agents/rbac_init.sql new file mode 100644 index 0000000..7e2b5de --- /dev/null +++ b/.agents/rbac_init.sql @@ -0,0 +1,503 @@ +-- RBAC / 用户 / 菜单 管理建表与初始化 SQL +-- PostgreSQL + +BEGIN; + +CREATE TABLE IF NOT EXISTS "user" ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + real_name VARCHAR(64), + password_hash VARCHAR(255) NOT NULL, + mobile VARCHAR(32), + email VARCHAR(128), + avatar VARCHAR(255), + status SMALLINT DEFAULT 1, + last_login_time TIMESTAMP NULL, + created_by BIGINT, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_user_status ON "user" (status); +CREATE INDEX IF NOT EXISTS idx_user_is_delete ON "user" (is_delete); + +CREATE TABLE IF NOT EXISTS role ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(64) NOT NULL, + description TEXT, + status SMALLINT DEFAULT 1, + is_system SMALLINT DEFAULT 0, + created_by BIGINT, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_role_status ON role (status); +CREATE INDEX IF NOT EXISTS idx_role_is_delete ON role (is_delete); + +CREATE TABLE IF NOT EXISTS permission ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(128) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + module VARCHAR(64), + action VARCHAR(64), + description TEXT, + status SMALLINT DEFAULT 1, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_permission_module ON permission (module); +CREATE INDEX IF NOT EXISTS idx_permission_status ON permission (status); + +CREATE TABLE IF NOT EXISTS menu ( + id BIGSERIAL PRIMARY KEY, + parent_id BIGINT DEFAULT 0, + name VARCHAR(64) NOT NULL, + code VARCHAR(64) UNIQUE, + type SMALLINT DEFAULT 1, + path VARCHAR(255), + component VARCHAR(255), + icon VARCHAR(64), + permission_code VARCHAR(128), + sort INTEGER DEFAULT 0, + visible SMALLINT DEFAULT 1, + status SMALLINT DEFAULT 1, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_menu_parent_id ON menu (parent_id); +CREATE INDEX IF NOT EXISTS idx_menu_sort ON menu (sort); + +CREATE TABLE IF NOT EXISTS user_role ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + role_id BIGINT NOT NULL, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_user_role UNIQUE (user_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_user_id ON user_role (user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_role_id ON user_role (role_id); + +CREATE TABLE IF NOT EXISTS role_permission ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + permission_id BIGINT NOT NULL, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_role_permission UNIQUE (role_id, permission_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_permission_role_id ON role_permission (role_id); +CREATE INDEX IF NOT EXISTS idx_role_permission_permission_id ON role_permission (permission_id); + +CREATE TABLE IF NOT EXISTS role_menu ( + id BIGSERIAL PRIMARY KEY, + role_id BIGINT NOT NULL, + menu_id BIGINT NOT NULL, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_role_menu UNIQUE (role_id, menu_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_menu_role_id ON role_menu (role_id); +CREATE INDEX IF NOT EXISTS idx_role_menu_menu_id ON role_menu (menu_id); + +INSERT INTO role (code, name, description, status, is_system, created_by, is_delete) +SELECT 'admin', '超级管理员', '系统内置超级管理员', 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM role WHERE code = 'admin'); + +INSERT INTO role (code, name, description, status, is_system, created_by, is_delete) +SELECT 'test_manager', '测试经理', '系统内置测试经理角色', 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM role WHERE code = 'test_manager'); + +INSERT INTO role (code, name, description, status, is_system, created_by, is_delete) +SELECT 'test_engineer', '测试工程师', '系统内置测试工程师角色', 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM role WHERE code = 'test_engineer'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:list', '角色列表', 'role', 'list', '查看角色列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:create', '创建角色', 'role', 'create', '创建角色', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:update', '更新角色', 'role', 'update', '更新角色', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:delete', '删除角色', 'role', 'delete', '删除角色', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:list', '用户列表', 'user', 'list', '查看用户列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:create', '创建用户', 'user', 'create', '创建用户', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:update', '更新用户', 'user', 'update', '更新用户', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:delete', '删除用户', 'user', 'delete', '删除用户', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:list', '权限列表', 'permission', 'list', '查看权限列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:list', '菜单列表', 'menu', 'list', '查看菜单树', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:list'); + +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:detail', '角色详情', 'role', 'detail', '查看角色详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:detail', '权限详情', 'permission', 'detail', '查看权限详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:create', '创建权限', 'permission', 'create', '创建权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:update', '更新权限', 'permission', 'update', '更新权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:delete', '删除权限', 'permission', 'delete', '删除权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:detail', '菜单详情', 'menu', 'detail', '查看菜单详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:create', '创建菜单', 'menu', 'create', '创建菜单', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:update', '更新菜单', 'menu', 'update', '更新菜单', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:delete', '删除菜单', 'menu', 'delete', '删除菜单', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:detail', '用户详情', 'user', 'detail', '查看用户详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:list', '项目列表', 'project', 'list', '查看项目列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:detail', '项目详情', 'project', 'detail', '查看项目详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:create', '创建项目', 'project', 'create', '创建项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:update', '更新项目', 'project', 'update', '更新项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:delete', '删除项目', 'project', 'delete', '删除项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project_member:list', '项目成员列表', 'project_member', 'list', '查看项目成员列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project_member:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project_member:create', '创建项目成员', 'project_member', 'create', '创建项目成员', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project_member:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'product:list', '产品列表', 'product', 'list', '查看产品列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'product:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'product:detail', '产品详情', 'product', 'detail', '查看产品详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'product:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'product:create', '创建产品', 'product', 'create', '创建产品', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'product:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'product:update', '更新产品', 'product', 'update', '更新产品', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'product:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'product:delete', '删除产品', 'product', 'delete', '删除产品', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'product:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'module:list', '模块列表', 'module', 'list', '查看模块列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'module:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'module:create', '创建模块', 'module', 'create', '创建模块', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'module:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'module:update', '更新模块', 'module', 'update', '更新模块', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'module:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'module:delete', '删除模块', 'module', 'delete', '删除模块', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'module:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:list', '用例列表', 'case', 'list', '查看用例列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:detail', '用例详情', 'case', 'detail', '查看用例详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:create', '创建用例', 'case', 'create', '创建用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:update', '更新用例', 'case', 'update', '更新用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:delete', '删除用例', 'case', 'delete', '删除用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case_snapshot:create', '创建用例快照', 'case_snapshot', 'create', '创建用例快照', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case_snapshot:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case_snapshot:list', '用例快照列表', 'case_snapshot', 'list', '查看用例快照列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case_snapshot:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case_review:create', '创建用例评审', 'case_review', 'create', '创建用例评审', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case_review:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case_review:update', '更新用例评审', 'case_review', 'update', '更新用例评审', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case_review:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case_review:list', '用例评审列表', 'case_review', 'list', '查看用例评审列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case_review:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:list', '计划列表', 'plan', 'list', '查看计划列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:detail', '计划详情', 'plan', 'detail', '查看计划详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:create', '创建计划', 'plan', 'create', '创建计划', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:update', '更新计划', 'plan', 'update', '更新计划', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:update'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:delete', '删除计划', 'plan', 'delete', '删除计划', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:progress', '计划进度', 'plan', 'progress', '查看计划进度', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:progress'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan_round:create', '创建计划轮次', 'plan_round', 'create', '创建计划轮次', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan_round:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan_round:list', '计划轮次列表', 'plan_round', 'list', '查看计划轮次列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan_round:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan_case:add', '添加计划用例', 'plan_case', 'add', '添加计划用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan_case:add'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan_case:list', '计划用例列表', 'plan_case', 'list', '查看计划用例列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan_case:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan_case:execute', '执行计划用例', 'plan_case', 'execute', '执行计划用例', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan_case:execute'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'report:list', '报告列表', 'report', 'list', '查看报告列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'report:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'report:detail', '报告详情', 'report', 'detail', '查看报告详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'report:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'report:generate', '生成报告', 'report', 'generate', '生成报告', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'report:generate'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'data_builder:list', '造数器列表', 'data_builder', 'list', '查看造数器列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'data_builder:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'sql_project:list', 'SQL项目列表', 'sql_project', 'list', '查看SQL项目列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'sql_project:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'sql_project:create', '创建SQL项目', 'sql_project', 'create', '创建SQL项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'sql_project:create'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'sql_project:detail', 'SQL项目详情', 'sql_project', 'detail', '查看SQL项目详情', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'sql_project:detail'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'sql_project:delete', '删除SQL项目', 'sql_project', 'delete', '删除SQL项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'sql_project:delete'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'sql_project:execute', '执行SQL项目', 'sql_project', 'execute', '执行SQL项目', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'sql_project:execute'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role_permission:list', '角色权限列表', 'role_permission', 'list', '查看角色权限列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role_permission:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role_permission:assign', '分配角色权限', 'role_permission', 'assign', '分配角色权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role_permission:assign'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role_menu:list', '角色菜单列表', 'role_menu', 'list', '查看角色菜单列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role_menu:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role_menu:assign', '分配角色菜单', 'role_menu', 'assign', '分配角色菜单', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role_menu:assign'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user_role:list', '用户角色列表', 'user_role', 'list', '查看用户角色列表', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user_role:list'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user_role:assign', '分配用户角色', 'user_role', 'assign', '分配用户角色', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user_role:assign'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'role:*', '角色全部权限', 'role', '*', '角色模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'role:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'user:*', '用户全部权限', 'user', '*', '用户模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'user:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'menu:*', '菜单全部权限', 'menu', '*', '菜单模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'menu:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'permission:*', '权限全部权限', 'permission', '*', '权限模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'permission:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'project:*', '项目全部权限', 'project', '*', '项目模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'project:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'environment:*', '环境全部权限', 'environment', '*', '环境模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'environment:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'case:*', '用例全部权限', 'case', '*', '用例模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'case:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'plan:*', '计划全部权限', 'plan', '*', '计划模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'plan:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'report:*', '报告全部权限', 'report', '*', '报告模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'report:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT 'data_builder:*', '造数器全部权限', 'data_builder', '*', '造数器模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = 'data_builder:*'); +INSERT INTO permission (code, name, module, action, description, status, is_delete) +SELECT '*:*', '全部权限', '*', '*', '所有模块全部权限', 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE code = '*:*'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT 0, '系统管理', 'system', 1, '/system', 'Layout', 'setting', NULL, 1, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'system'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '角色管理', 'role_manage', 2, '/system/role', 'system/role/index', 'peoples', 'role:list', 1, 1, 1, 0 +FROM menu m +WHERE m.code = 'system' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'role_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '用户管理', 'user_manage', 2, '/system/user', 'system/user/index', 'user', 'user:list', 2, 1, 1, 0 +FROM menu m +WHERE m.code = 'system' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'user_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '权限管理', 'permission_manage', 2, '/system/permission', 'system/permission/index', 'lock', 'permission:list', 3, 1, 1, 0 +FROM menu m +WHERE m.code = 'system' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'permission_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '菜单管理', 'menu_manage', 2, '/system/menu', 'system/menu/index', 'menu', 'menu:list', 4, 1, 1, 0 +FROM menu m +WHERE m.code = 'system' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'menu_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT 0, '测试平台', 'test_platform', 1, '/test-platform', 'Layout', 'platform', NULL, 2, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'test_platform'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '产品管理', 'product_manage', 2, '/test-platform/product', 'test-platform/product/index', 'product', 'product:list', 1, 1, 1, 0 +FROM menu m +WHERE m.code = 'test_platform' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'product_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '项目管理', 'project_manage', 2, '/test-platform/project', 'test-platform/project/index', 'project', 'project:list', 2, 1, 1, 0 +FROM menu m +WHERE m.code = 'test_platform' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'project_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '用例管理', 'case_manage', 2, '/test-platform/case', 'test-platform/case/index', 'case', 'case:list', 3, 1, 1, 0 +FROM menu m +WHERE m.code = 'test_platform' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'case_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '测试计划', 'plan_manage', 2, '/test-platform/plan', 'test-platform/plan/index', 'plan', 'plan:list', 4, 1, 1, 0 +FROM menu m +WHERE m.code = 'test_platform' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'plan_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '测试报告', 'report_manage', 2, '/test-platform/report', 'test-platform/report/index', 'report', 'report:list', 5, 1, 1, 0 +FROM menu m +WHERE m.code = 'test_platform' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'report_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT 0, '造数工具', 'data_tools', 1, '/data-tools', 'Layout', 'data', NULL, 3, 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE code = 'data_tools'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '数据库造数', 'data_builder_manage', 2, '/data-tools/db-builder', 'data-tools/db-builder/index', 'database', 'data_builder:list', 1, 1, 1, 0 +FROM menu m +WHERE m.code = 'data_tools' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'data_builder_manage'); + +INSERT INTO menu (parent_id, name, code, type, path, component, icon, permission_code, sort, visible, status, is_delete) +SELECT m.id, '造数工厂', 'data_factory_manage', 2, '/data-tools/factory', 'data-tools/factory/index', 'factory', NULL, 2, 1, 1, 0 +FROM menu m +WHERE m.code = 'data_tools' + AND NOT EXISTS (SELECT 1 FROM menu WHERE code = 'data_factory_manage'); + +INSERT INTO "user" (username, real_name, password_hash, mobile, email, avatar, status, created_by, is_delete) +SELECT 'admin', '系统管理员', 'admin123', '13800000000', 'admin@example.com', '', 1, 1, 0 +WHERE NOT EXISTS (SELECT 1 FROM "user" WHERE username = 'admin'); + +INSERT INTO user_role (user_id, role_id, is_delete) +SELECT u.id, r.id, 0 +FROM "user" u, role r +WHERE u.username = 'admin' AND r.code = 'admin' + AND NOT EXISTS ( + SELECT 1 FROM user_role ur WHERE ur.user_id = u.id AND ur.role_id = r.id + ); + +INSERT INTO role_permission (role_id, permission_id, is_delete) +SELECT r.id, p.id, 0 +FROM role r, permission p +WHERE r.code = 'admin' + AND NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (role_id, permission_id, is_delete) +SELECT r.id, p.id, 0 +FROM role r, permission p +WHERE r.code = 'admin' + AND p.code IN ('user_role:list', 'user_role:assign', 'role_permission:list', 'role_permission:assign', 'role_menu:list', 'role_menu:assign') + AND NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.role_id = r.id AND rp.permission_id = p.id + ); + +UPDATE menu SET permission_code = 'role:list' WHERE code = 'role_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'user:list' WHERE code = 'user_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'permission:list' WHERE code = 'permission_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'menu:list' WHERE code = 'menu_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'product:list' WHERE code = 'product_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'project:list' WHERE code = 'project_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'case:list' WHERE code = 'case_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'plan:list' WHERE code = 'plan_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'report:list' WHERE code = 'report_manage' AND (permission_code IS NULL OR permission_code = ''); +UPDATE menu SET permission_code = 'data_builder:list' WHERE code = 'data_builder_manage' AND (permission_code IS NULL OR permission_code = ''); + +INSERT INTO role_menu (role_id, menu_id, is_delete) +SELECT r.id, m.id, 0 +FROM role r, menu m +WHERE r.code = 'admin' + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.role_id = r.id AND rm.menu_id = m.id + ); + +COMMIT; diff --git a/.plan/3onvvJGzAx9Dhi05JkVpx.md b/.plan/3onvvJGzAx9Dhi05JkVpx.md new file mode 100644 index 0000000..a127336 --- /dev/null +++ b/.plan/3onvvJGzAx9Dhi05JkVpx.md @@ -0,0 +1,212 @@ +# 实施计划:按现有架构接入测试管理模块 + +## 目标 +在当前 Flask + SQLAlchemy 手写分层架构下,按现有模式扩展测试管理能力。保持 `model/dao/service/controller/views` 分层,不引入 FastAPI、异步框架、Celery、WebSocket 等大改架构内容。先实现同步 CRUD 与核心数据流,异步任务/实时推送/外部缺陷系统作为后续阶段。 + +## 当前架构约束 +- 路由集中在 `app/api/views.py` 的 Flask Blueprint `api` 下。 +- Controller 位于 `app/api/controller/*Controller.py`。 +- Service 位于 `app/api/service/*Service.py`。 +- DAO 位于 `app/api/dao/*Dao.py`。 +- Model 位于 `app/api/model/*Model.py`,使用 SQLAlchemy declarative。 +- 数据库连接使用 `common/sqlSession.py` 的 `SqlSession`。 +- 响应统一使用 `common/apiResponse.py`。 + +## 命名与接口策略 +当前已有接口路径是 `/it/api/...` 风格,不采用设计稿中的 `/api/v1/projects/{project_id}/...` 作为硬切换。 + +新增接口建议挂在同一个 Blueprint 下,保持风格: +- 项目:`/project/list`、`/project/detail`、`/project/create`、`/project/update`、`/project/delete` +- 环境:`/environment/list`、`/environment/create`、`/environment/update`、`/environment/delete` +- 模块:`/module/tree`、`/module/create`、`/module/update`、`/module/delete` +- 用例:`/case/list`、`/case/detail`、`/case/create`、`/case/update`、`/case/delete` +- 用例快照:`/case/snapshot/create`、`/case/snapshot/list` +- 用例评审:`/case/review/create`、`/case/review/update`、`/case/review/list` +- 测试计划:`/plan/list`、`/plan/detail`、`/plan/create`、`/plan/update`、`/plan/delete` +- 计划用例:`/plan/case/add`、`/plan/case/list`、`/plan/case/execute` +- 轮次:`/plan/round/list`、`/plan/round/create` +- 报告:`/report/list`、`/report/detail`、`/report/generate` +- 造数:`/data/builder/list`、`/data/builder/detail`、`/data/builder/create`、`/data/builder/update`、`/data/builder/delete`、`/data/builder/execute`、`/data/task/status` + +## 阶段 1:模型层与基础 DAO +新增 model 文件: +- `projectModel.py` + - `Project` + - `ProjectMember` + - `Environment` +- `caseModel.py` + - `Module` + - `TestCase` + - `CaseSnapshot` + - `CaseReview` +- `planModel.py` + - `TestPlan` + - `PlanCase` + - `TestRound` +- `reportModel.py` + - `Report` + - `DefectSync` +- `dataBuilderModel.py` + - `DataBuilder` + - `DataTask` + +实现细节: +- PostgreSQL `JSONB` 使用 `sqlalchemy.dialects.postgresql.JSONB`。 +- PostgreSQL 数组 `tags VARCHAR(64)[]` 使用 `ARRAY(String(64))`。 +- `BIGSERIAL` 模型上用 `BigInteger` + `Sequence` 或直接 `BigInteger primary_key=True autoincrement=True`,保持 SQLAlchemy 兼容。 +- 所有 `created_time/updated_time` 使用 `TIMESTAMP, server_default=text('CURRENT_TIMESTAMP')`,风格参考现有 `UpdateSqlProject`。 +- 如需软删除,设计稿多数表未含 `is_delete`。为了保持删除接口一致性,优先给业务主表补 `is_delete` 字段:`project`、`module`、`test_case`、`test_plan`、`data_builder`。关联/历史表可物理保留不删。 + +新增 DAO 文件: +- `projectDao.py` +- `caseDao.py` +- `planDao.py` +- `reportDao.py` +- `dataBuilderDao.py` + +DAO 公共实现规则: +- 列表方法统一接收 `filter_list, page, limit`。 +- 详情方法统一按 `id` + `is_delete=0` 查询。 +- 删除方法统一更新 `is_delete=1`。 +- 创建/更新方法返回 `(id, err_msg)` 或 `(obj, err_msg)`,与现有 `UpdateSqlProjectDao` 保持一致。 + +## 阶段 2:项目、环境、模块、用例基础 CRUD +新增 Service: +- `projectService.py` +- `caseService.py` + +新增 Controller: +- `projectController.py` +- `caseController.py` + +先实现: +- 项目 CRUD +- 项目成员新增/删除/列表 +- 环境 CRUD +- 模块树 CRUD +- 用例 CRUD +- 用例详情返回不暴露 `is_delete` +- 用例列表支持:`projectId/moduleId/priority/caseType/status/tags/keyword/pageNo/pageSize` + +关键逻辑: +- 创建用例时生成 `case_key`,项目内递增,如 `TC-001`。初版可按当前项目最大 id 或 count 生成,后续再优化并发锁。 +- 更新用例时可选生成快照,初版提供单独 `/case/snapshot/create`。 +- 删除用例只更新 `test_case.is_delete=1`。 + +## 阶段 3:评审与快照 +在 `caseDao/service/controller` 内补: +- 创建快照 +- 查询快照列表 +- 创建评审记录 +- 更新评审状态/评论 + +接口: +- `POST /case/snapshot/create` body: `{caseId, createdBy}` +- `GET /case/snapshot/list?caseId=...` +- `POST /case/review/create` body: `{caseId, reviewerId, comments?}` +- `POST /case/review/update` body: `{reviewId, status, comments?}` +- `GET /case/review/list?caseId=...` + +## 阶段 4:测试计划与执行闭环 +新增 Service/Controller: +- `planService.py` +- `planController.py` + +实现: +- 测试计划 CRUD +- 轮次创建/列表 +- 批量添加用例到计划 +- 执行计划用例,更新状态、实际结果、缺陷链接、附件、执行时间、执行耗时 +- 计划详情聚合统计:`total_cases/completed/pass_rate/passed/failed/blocked` +- 进度接口:按轮次、执行人聚合 + +接口: +- `GET /plan/list` +- `POST /plan/create` +- `GET /plan/detail?planId=...` +- `POST /plan/delete` +- `POST /plan/case/add` +- `GET /plan/case/list?planId=...&roundNo=...` +- `POST /plan/case/execute` +- `GET /plan/progress?planId=...` + +初版不自动创建外部缺陷,只保存 `defect_links`。外部 JIRA/TAPD/禅道集成后续单独做。 + +## 阶段 5:报告 +新增: +- `reportService.py` +- `reportController.py` +- `reportDao.py` + +实现同步版报告生成: +- 根据 `plan_id` 聚合 `plan_case` 状态 +- 保存 `report.summary` 和简单 HTML `content` +- 列表/详情查询 + +接口: +- `GET /report/list?planId=&pageNo=&pageSize=` +- `POST /report/generate` body: `{planId, generatedBy}` +- `GET /report/detail?reportId=...` + +不实现 PDF 导出、异步任务、模板编辑器;这些需后续引入任务队列/文件服务。 + +## 阶段 6:造数模块 MVP +新增: +- `dataBuilderService.py` +- `dataBuilderController.py` +- `dataBuilderDao.py` +- `common/dataBuilderExecutor.py` + +实现: +- 造数器 CRUD +- 同步执行 JSON 定义 +- 支持 step type:`http`、`db` +- 先不支持不安全的 `script exec`,避免安全风险 +- 执行结果保存到 `data_task` + +接口: +- `GET /data/builder/list?projectId=...` +- `POST /data/builder/create` +- `GET /data/builder/detail?builderId=...` +- `POST /data/builder/update` +- `POST /data/builder/delete` +- `POST /data/builder/execute` body: `{builderId, params, async:false}` +- `GET /data/task/status?taskId=...` + +## 阶段 7:路由注册 +修改 `app/api/views.py`: +- 保留现有 SQL 管理接口。 +- 追加新模块路由。 +- 每个路由实例化对应 Controller,返回 `ApiResponse`。 + +为避免 `views.py` 过大,若当前应用允许多 Blueprint,后续可拆 `projectViews.py/caseViews.py/...`;本次先保持单文件,符合现有架构。 + +## 阶段 8:数据库初始化 SQL +新增建表 SQL 文件需要用户确认放置位置。建议: +- `sql/test_management_schema.sql` + +注意:当前仓库不应主动创建文档,但 schema SQL 属于代码资产,可在用户确认后创建。 + +## 阶段 9:验证 +最低验证: +- Python 语法检查:`python -m py_compile` 对新增/改动 py 文件。 +- 手动接口链路: + 1. 创建项目 + 2. 创建模块 + 3. 创建用例 + 4. 查询用例列表/详情 + 5. 创建计划 + 6. 添加用例到计划 + 7. 执行用例 + 8. 生成报告 + +## 非本次直接实现范围 +- WebSocket 实时推送 +- Celery 异步任务 +- PDF 导出/OSS 上传 +- 外部缺陷系统自动创建 +- 前端页面 +- Docker Compose +- Python 脚本沙箱执行 + +这些会影响架构和依赖,需要单独确认后再做。 \ No newline at end of file diff --git a/.plan/YCGiVLWod2rghU8nT3fEv.md b/.plan/YCGiVLWod2rghU8nT3fEv.md new file mode 100644 index 0000000..80d941f --- /dev/null +++ b/.plan/YCGiVLWod2rghU8nT3fEv.md @@ -0,0 +1,856 @@ +# 角色/用户/菜单管理详细设计文档 + +## 1. 目标 + +基于当前项目已有的分层框架(model / dao / service / controller / views)新增以下能力: + +1. 角色管理 + - 角色表 + - 权限表 + - 用户角色关联表 + - 角色权限关联能力 +2. 用户管理 + - 用户表 + - 用户与角色分配 +3. 菜单管理 + - 菜单表 + - 角色分配菜单 + +本次仅输出详细设计,不直接改业务代码。 + +--- + +## 2. 当前项目框架观察 + +### 2.1 现有分层风格 + +当前项目采用如下结构: + +- `app/api/model/*.py` +- `app/api/dao/*.py` +- `app/api/service/*.py` +- `app/api/controller/*.py` +- `app/api/views.py` + +### 2.2 当前职责边界 + +#### model +- 定义 SQLAlchemy 表模型 +- 每个领域通常独立一个 model 文件 + +#### dao +- 负责数据库增删改查 +- 已有通用风格: + - `create` + - `update_by_id` + - `get_by_id` + - `list_by_filters` + - `delete_by_id` + +#### service +- 作为 controller 与 dao 之间的业务入口 +- 目前多数是对 dao 的薄封装 +- 适合放跨表组合查询、业务校验、聚合逻辑 + +#### controller +- 负责参数获取、必填校验、字段映射、返回结构拼装 +- 不应直接 `session.query(...)` + +#### views +- Flask 路由层 +- 负责 request 参数读取、ApiResponse 包装、session 生命周期控制 + +### 2.3 当前接口命名风格 + +已有接口风格: +- `/project/list` +- `/project/detail` +- `/project/create` +- `/project/update` +- `/project/delete` +- `/product/list` +- `/product/detail` +- `/product/create` +- `/product/update` +- `/product/delete` + +建议新增模块保持一致: +- `/role/list` +- `/role/detail` +- `/role/create` +- `/role/update` +- `/role/delete` +- `/user/list` +- `/user/detail` +- `/user/create` +- `/user/update` +- `/user/delete` +- `/menu/tree` +- `/menu/create` +- `/menu/update` +- `/menu/delete` +- `/role/permission/*` +- `/role/menu/*` +- `/user/role/*` + +--- + +## 3. 总体设计原则 + +1. 严格按当前框架分层 +2. controller 不直接查数据库 +3. 跨表聚合通过 service + dao 实现 +4. 保持与现有字段风格一致: + - 主键 `id` + - 软删字段 `is_delete` + - 时间字段 `created_time` / `updated_time` +5. 优先支持后台管理能力,不先引入复杂 RBAC 继承体系 +6. 菜单、权限、角色、用户四个域解耦,但通过关联表关联 + +--- + +## 4. 业务对象与关系 + +### 4.1 核心对象 + +1. 用户 `user` +2. 角色 `role` +3. 权限 `permission` +4. 菜单 `menu` + +### 4.2 关联对象 + +1. 用户角色关联 `user_role` +2. 角色权限关联 `role_permission` +3. 角色菜单关联 `role_menu` + +### 4.3 关系说明 + +- 用户 : 角色 = 多对多 +- 角色 : 权限 = 多对多 +- 角色 : 菜单 = 多对多 +- 菜单自身 = 树形父子关系 + +### 4.4 推荐鉴权思路 + +后续登录后: +- 用户拥有多个角色 +- 角色聚合出权限集合 +- 角色聚合出菜单集合 +- 前端基于菜单渲染导航 +- 后端基于权限点做接口校验 + +--- + +## 5. 数据库表结构设计 + +以下采用 PostgreSQL 风格,保持与现有模型一致。 + +--- + +## 5.1 用户表 `user` + +### 用途 +存储后台系统用户基础信息。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| username | VARCHAR(64) | UNIQUE NOT NULL | 登录用户名 | +| real_name | VARCHAR(64) | | 真实姓名 | +| password_hash | VARCHAR(255) | NOT NULL | 密码哈希 | +| mobile | VARCHAR(32) | | 手机号 | +| email | VARCHAR(128) | | 邮箱 | +| avatar | VARCHAR(255) | | 头像地址 | +| status | SMALLINT | DEFAULT 1 | 1启用 0禁用 | +| last_login_time | TIMESTAMP | | 最后登录时间 | +| created_by | BIGINT | | 创建人 | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | +| updated_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | + +### 索引建议 +- unique(username) +- index(status) +- index(is_delete) + +--- + +## 5.2 角色表 `role` + +### 用途 +定义系统角色。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| code | VARCHAR(64) | UNIQUE NOT NULL | 角色编码,如 admin/test_manager | +| name | VARCHAR(64) | NOT NULL | 角色名称 | +| description | TEXT | | 角色描述 | +| status | SMALLINT | DEFAULT 1 | 1启用 0禁用 | +| is_system | SMALLINT | DEFAULT 0 | 是否系统内置角色 | +| created_by | BIGINT | | 创建人 | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | +| updated_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | + +### 索引建议 +- unique(code) +- index(status) +- index(is_delete) + +--- + +## 5.3 权限表 `permission` + +### 用途 +定义后端权限点。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| code | VARCHAR(128) | UNIQUE NOT NULL | 权限编码,如 project:create | +| name | VARCHAR(128) | NOT NULL | 权限名称 | +| module | VARCHAR(64) | | 所属模块,如 project/user/menu | +| action | VARCHAR(64) | | 动作,如 list/create/update/delete | +| description | TEXT | | 描述 | +| status | SMALLINT | DEFAULT 1 | 1启用 0禁用 | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | +| updated_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | + +### 索引建议 +- unique(code) +- index(module) +- index(status) + +--- + +## 5.4 菜单表 `menu` + +### 用途 +定义前端菜单树。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| parent_id | BIGINT | DEFAULT 0 | 父菜单ID,0表示根节点 | +| name | VARCHAR(64) | NOT NULL | 菜单名称 | +| code | VARCHAR(64) | UNIQUE | 菜单编码 | +| type | SMALLINT | DEFAULT 1 | 1目录 2菜单 3按钮 | +| path | VARCHAR(255) | | 路由路径 | +| component | VARCHAR(255) | | 前端组件路径 | +| icon | VARCHAR(64) | | 图标 | +| permission_code | VARCHAR(128) | | 对应权限编码,可选 | +| sort | INTEGER | DEFAULT 0 | 排序 | +| visible | SMALLINT | DEFAULT 1 | 1显示 0隐藏 | +| status | SMALLINT | DEFAULT 1 | 1启用 0禁用 | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | +| updated_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 | + +### 索引建议 +- index(parent_id) +- unique(code) +- index(sort) + +--- + +## 5.5 用户角色关联表 `user_role` + +### 用途 +维护用户与角色多对多关系。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| user_id | BIGINT | NOT NULL | 用户ID | +| role_id | BIGINT | NOT NULL | 角色ID | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | + +### 约束建议 +- unique(user_id, role_id) +- index(user_id) +- index(role_id) + +--- + +## 5.6 角色权限关联表 `role_permission` + +### 用途 +维护角色与权限多对多关系。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| role_id | BIGINT | NOT NULL | 角色ID | +| permission_id | BIGINT | NOT NULL | 权限ID | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | + +### 约束建议 +- unique(role_id, permission_id) +- index(role_id) +- index(permission_id) + +--- + +## 5.7 角色菜单关联表 `role_menu` + +### 用途 +维护角色与菜单多对多关系。 + +### 建表字段 + +| 字段 | 类型 | 约束 | 说明 | +|---|---|---|---| +| id | BIGSERIAL | PK | 主键 | +| role_id | BIGINT | NOT NULL | 角色ID | +| menu_id | BIGINT | NOT NULL | 菜单ID | +| is_delete | INTEGER | DEFAULT 0 | 软删标识 | +| created_time | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | + +### 约束建议 +- unique(role_id, menu_id) +- index(role_id) +- index(menu_id) + +--- + +## 6. Model 设计 + +建议新增单独 model 文件: + +### 6.1 `app/api/model/userModel.py` +包含: +- `User` +- `UserRole` + +### 6.2 `app/api/model/rbacModel.py` +包含: +- `Role` +- `Permission` +- `RolePermission` +- `Menu` +- `RoleMenu` + +> 也可以拆成 `roleModel.py` / `menuModel.py`,但按当前项目规模,两个文件足够。 + +### 6.3 Model 设计原则 +- 继承与现有一致:`declarative_base()` + `to_dict` +- 字段命名与数据库列一致 +- 关联关系不强制写 SQLAlchemy relationship,保持当前项目风格简洁 + +--- + +## 7. DAO 设计 + +--- + +## 7.1 `app/api/dao/userDao.py` + +### 基础方法 +- `create(session, model_cls, add_info)` +- `update_by_id(session, model_cls, obj_id, update_info, soft_delete=True)` +- `get_by_id(session, model_cls, obj_id, soft_delete=True)` +- `list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None)` +- `delete_by_id(session, model_cls, obj_id)` + +### 用户专项方法 +- `get_user_role_ids(session, user_id)` +- `replace_user_roles(session, user_id, role_ids)` +- `get_user_roles(session, user_ids)` + +--- + +## 7.2 `app/api/dao/rbacDao.py` + +### 基础方法 +同现有通用 DAO 风格。 + +### 角色/权限/菜单专项方法 +- `get_role_permission_ids(session, role_id)` +- `replace_role_permissions(session, role_id, permission_ids)` +- `get_role_menu_ids(session, role_id)` +- `replace_role_menus(session, role_id, menu_ids)` +- `get_role_names_map(session, role_ids)` +- `get_menu_tree_items(session, filter_list)` +- `get_permission_list(session, filter_list, page, limit)` + +--- + +## 8. Service 设计 + +--- + +## 8.1 `app/api/service/userService.py` + +### 职责 +- 用户基本 CRUD +- 用户与角色分配 +- 聚合用户角色名称 + +### 方法建议 +- `create(session, model_cls, add_info)` +- `update_by_id(session, model_cls, obj_id, update_info, soft_delete=True)` +- `get_by_id(session, model_cls, obj_id, soft_delete=True)` +- `list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None)` +- `delete_by_id(session, model_cls, obj_id)` +- `assign_roles(session, user_id, role_ids)` +- `get_user_role_ids(session, user_id)` +- `get_user_roles_map(session, user_ids)` + +--- + +## 8.2 `app/api/service/rbacService.py` + +### 职责 +- 角色 CRUD +- 权限 CRUD +- 菜单 CRUD +- 角色绑定权限 +- 角色绑定菜单 +- 菜单树构建 + +### 方法建议 +- `create(session, model_cls, add_info)` +- `update_by_id(session, model_cls, obj_id, update_info, soft_delete=True)` +- `get_by_id(session, model_cls, obj_id, soft_delete=True)` +- `list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None)` +- `delete_by_id(session, model_cls, obj_id)` +- `assign_permissions(session, role_id, permission_ids)` +- `assign_menus(session, role_id, menu_ids)` +- `get_role_permission_ids(session, role_id)` +- `get_role_menu_ids(session, role_id)` +- `build_menu_tree(session, filters)` + +--- + +## 9. Controller 设计 + +controller 仅负责: +- 参数获取 `_get(...)` +- 必填校验 +- 字段映射 +- 调用 service +- 序列化返回 + +不得直接 `session.query(...)` + +--- + +## 9.1 `app/api/controller/userController.py` + +### 建议方法 +- `user_list` +- `user_detail` +- `user_create` +- `user_update` +- `user_delete` +- `user_role_assign` +- `user_role_list` + +### 返回增强建议 +`user_list` 每项增加: +- `role_ids` +- `role_names` + +--- + +## 9.2 `app/api/controller/rbacController.py` + +### 角色相关 +- `role_list` +- `role_detail` +- `role_create` +- `role_update` +- `role_delete` +- `role_permission_assign` +- `role_permission_list` +- `role_menu_assign` +- `role_menu_list` + +### 权限相关 +- `permission_list` +- `permission_detail` +- `permission_create` +- `permission_update` +- `permission_delete` + +### 菜单相关 +- `menu_tree` +- `menu_detail` +- `menu_create` +- `menu_update` +- `menu_delete` + +--- + +## 10. 接口清单设计 + +以下接口路径保持当前项目风格。 + +--- + +## 10.1 角色管理接口 + +### 角色列表 +- `GET /role/list` +- 参数:`keyword`, `status`, `pageNo`, `pageSize` +- 返回:角色分页列表 + +### 角色详情 +- `GET /role/detail` +- 参数:`roleId` + +### 创建角色 +- `POST /role/create` +- 参数: + - `code` + - `name` + - `description` + - `status` + - `isSystem` + +### 更新角色 +- `POST /role/update` +- 参数: + - `roleId` + - 可选更新字段 + +### 删除角色 +- `POST /role/delete` +- 参数:`roleId` + +--- + +## 10.2 权限管理接口 + +### 权限列表 +- `GET /permission/list` +- 参数:`keyword`, `module`, `status`, `pageNo`, `pageSize` + +### 权限详情 +- `GET /permission/detail` +- 参数:`permissionId` + +### 创建权限 +- `POST /permission/create` + +### 更新权限 +- `POST /permission/update` + +### 删除权限 +- `POST /permission/delete` + +--- + +## 10.3 菜单管理接口 + +### 菜单树 +- `GET /menu/tree` +- 参数:可选 `status` +- 返回树形结构 + +### 菜单详情 +- `GET /menu/detail` +- 参数:`menuId` + +### 创建菜单 +- `POST /menu/create` + +### 更新菜单 +- `POST /menu/update` + +### 删除菜单 +- `POST /menu/delete` + +--- + +## 10.4 角色权限分配接口 + +### 查询角色权限 +- `GET /role/permission/list` +- 参数:`roleId` +- 返回:`permissionIds` + 权限明细列表 + +### 分配角色权限 +- `POST /role/permission/assign` +- 参数: + - `roleId` + - `permissionIds: []` + +建议语义:覆盖式保存 + +--- + +## 10.5 角色菜单分配接口 + +### 查询角色菜单 +- `GET /role/menu/list` +- 参数:`roleId` +- 返回:`menuIds` + 菜单树 + +### 分配角色菜单 +- `POST /role/menu/assign` +- 参数: + - `roleId` + - `menuIds: []` + +建议语义:覆盖式保存 + +--- + +## 10.6 用户管理接口 + +### 用户列表 +- `GET /user/list` +- 参数:`keyword`, `status`, `pageNo`, `pageSize` + +### 用户详情 +- `GET /user/detail` +- 参数:`userId` + +### 创建用户 +- `POST /user/create` +- 参数: + - `username` + - `realName` + - `password` + - `mobile` + - `email` + - `status` + +### 更新用户 +- `POST /user/update` +- 参数: + - `userId` + - 可选更新字段 + +### 删除用户 +- `POST /user/delete` +- 参数:`userId` + +--- + +## 10.7 用户角色分配接口 + +### 查询用户角色 +- `GET /user/role/list` +- 参数:`userId` +- 返回:`roleIds` + 角色列表 + +### 分配用户角色 +- `POST /user/role/assign` +- 参数: + - `userId` + - `roleIds: []` + +建议语义:覆盖式保存 + +--- + +## 11. 返回结构建议 + +### 11.1 角色列表项 + +```json +{ + "id": 1, + "code": "admin", + "name": "管理员", + "description": "系统管理员", + "status": 1, + "is_system": 1, + "created_time": "2025-01-01 10:00:00" +} +``` + +### 11.2 用户列表项 + +```json +{ + "id": 1, + "username": "zhangsan", + "real_name": "张三", + "mobile": "13800000000", + "email": "a@test.com", + "status": 1, + "role_ids": [1, 2], + "role_names": ["管理员", "测试经理"] +} +``` + +### 11.3 菜单树项 + +```json +{ + "id": 1, + "parent_id": 0, + "name": "系统管理", + "code": "system", + "type": 1, + "path": "/system", + "component": "Layout", + "icon": "setting", + "sort": 1, + "children": [] +} +``` + +--- + +## 12. 文件清单 + +本次功能落地预计新增/修改以下文件。 + +### 12.1 新增 model +- `app/api/model/userModel.py` +- `app/api/model/rbacModel.py` + +### 12.2 新增 dao +- `app/api/dao/userDao.py` +- `app/api/dao/rbacDao.py` + +### 12.3 新增 service +- `app/api/service/userService.py` +- `app/api/service/rbacService.py` + +### 12.4 新增 controller +- `app/api/controller/userController.py` +- `app/api/controller/rbacController.py` + +### 12.5 修改 views +- `app/api/views.py` + +### 12.6 可能新增 SQL 脚本(如果你需要) +- 数据库建表 SQL +- 初始化菜单/权限/角色种子数据 SQL + +--- + +## 13. 分层职责清单 + +### model +- 只定义表结构 + +### dao +- 只做数据库查询/写入 +- 负责关联表的增删查 + +### service +- 负责组合查询、覆盖式分配逻辑、聚合返回结构前的数据准备 + +### controller +- 参数校验、字段映射、调用 service、返回结构组装 + +### views +- 路由注册、request/response 包装 + +--- + +## 14. 推荐实现顺序 + +### 第一阶段:基础 RBAC 数据层 +1. 建表 SQL +2. model +3. dao +4. service + +### 第二阶段:角色/权限/菜单接口 +1. role CRUD +2. permission CRUD +3. menu CRUD +4. role-permission assign +5. role-menu assign + +### 第三阶段:用户接口 +1. user CRUD +2. user-role assign +3. user list/detail 聚合角色信息 + +### 第四阶段:初始化与联调 +1. 初始化菜单 +2. 初始化权限 +3. 初始化管理员角色 +4. 初始化管理员用户 + +--- + +## 15. 关键设计决策 + +### 15.1 分配类接口采用覆盖式保存 +原因: +- 前端实现简单 +- 后端一致性更好 +- 避免增删混合接口过多 + +### 15.2 菜单与权限分离 +原因: +- 菜单是导航资源 +- 权限是操作资源 +- 一些按钮权限不一定出现在菜单树中 + +### 15.3 用户表单独建设,不复用现有 project_member +原因: +- `project_member` 是项目成员关系,不是系统账户主表 +- 用户管理需要登录凭据、状态、手机号、邮箱等字段 + +### 15.4 先不引入复杂组织架构 +原因: +- 当前需求聚焦 RBAC + 用户 + 菜单 +- 可后续扩展部门/租户/产品维度数据权限 + +--- + +## 16. 后续可扩展项 + +1. 登录认证 + - JWT / Session +2. 密码重置 +3. 数据权限 + - 按产品/项目/部门的数据访问范围 +4. 操作审计日志 +5. 菜单与接口权限自动同步 +6. 超级管理员保护机制 + +--- + +## 17. 需要你确认的点 + +请你确认以下设计取舍: + +1. 用户表是否需要登录密码字段(默认:需要) +2. 权限是否按 `module:action` 编码(默认:是) +3. 菜单是否需要按钮类型 `type=3`(默认:需要) +4. 分配接口是否采用覆盖式保存(默认:是) +5. 是否需要初始化内置角色: + - 超级管理员 + - 测试经理 + - 测试工程师 +6. 用户是否允许多角色(默认:允许) +7. 菜单是否只做系统后台菜单,不含项目业务模块动态菜单(默认:只做后台菜单) + +--- + +## 18. 下一步建议 + +如果你确认这份设计,我下一步可以继续给你输出两部分: + +1. **完整建表 SQL** +2. **按当前项目风格拆好的代码落地清单** + - 每个文件该写哪些类、哪些方法、哪些接口 + diff --git a/Jenkinsfile b/Jenkinsfile index 79af542..63d5231 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -122,8 +122,11 @@ pipeline { sshCommand remote: remote, sudo: false, command: """ set -e - docker ps --filter name=${CONTAINER_NAME} - curl -I http://127.0.0.1:${HOST_PORT} + sleep 10 + docker ps -a --filter name=${CONTAINER_NAME} + docker inspect ${CONTAINER_NAME} --format '{{.State.Status}}' + docker logs --tail 100 ${CONTAINER_NAME} || true + curl -v http://127.0.0.1:${HOST_PORT} """ } } @@ -168,14 +171,15 @@ def deploy_node_by_password(Map args) { remote.password = DEPLOY_PASS def deployCommand = """ - echo '${HARBOR_PASS}' | docker login '${REGISTRY}' -u '${HARBOR_USER}' --password-stdin && - docker pull '${IMAGE_NAME}:latest' && - docker rm -f '${CONTAINER_NAME}' || true && + set -e + echo '${HARBOR_PASS}' | docker login '${REGISTRY}' -u '${HARBOR_USER}' --password-stdin + docker pull '${IMAGE_NAME}:latest' + docker rm -f '${CONTAINER_NAME}' || true docker run -d \ --name '${CONTAINER_NAME}' \ --restart always \ - -p '${HOST_PORT}:${CONTAINER_PORT}' \ - '${IMAGE_NAME}:latest' && + --network host \ + '${IMAGE_NAME}:latest' docker logout '${REGISTRY}' || true """ diff --git a/__pycache__/const.cpython-38.pyc b/__pycache__/const.cpython-38.pyc new file mode 100644 index 0000000..ea7ac1c Binary files /dev/null and b/__pycache__/const.cpython-38.pyc differ diff --git a/__pycache__/logger.cpython-38.pyc b/__pycache__/logger.cpython-38.pyc new file mode 100644 index 0000000..cde0fbb Binary files /dev/null and b/__pycache__/logger.cpython-38.pyc differ diff --git a/api_test_document.md b/api_test_document.md new file mode 100644 index 0000000..bcfe64f --- /dev/null +++ b/api_test_document.md @@ -0,0 +1,416 @@ +# API 测试文档 + +## 1. 接口概览 + +本项目基于 Flask 框架实现,提供了完整的测试平台 API,包括项目管理、用例管理、计划执行、报告生成、权限管理等功能。 + +### 基础信息 +- 基础 URL: `http://localhost:8081/it/api` +- 认证方式: `accessToken` 头部 +- 响应格式: JSON + +## 2. 模块分类 + +| 模块 | 前缀 | 主要功能 | +|------|------|----------| +| SQL 项目 | `/` | SQL 项目管理 | +| 项目管理 | `/project` | 项目 CRUD、环境管理 | +| 项目成员 | `/project/member` | 项目成员管理 | +| 产品管理 | `/product` | 产品 CRUD | +| 模块管理 | `/module` | 测试模块管理 | +| 用例管理 | `/case` | 测试用例 CRUD、快照、评审 | +| 计划管理 | `/plan` | 测试计划、轮次、执行 | +| 报告管理 | `/report` | 测试报告生成与查询 | +| Bug 管理 | `/bug` | Bug 追踪与管理 | +| 造数器 | `/data` | 数据构建与任务管理 | +| 权限管理 | `/role`, `/permission`, `/menu` | 角色、权限、菜单管理 | +| 用户管理 | `/user` | 用户 CRUD、角色分配 | +| 认证 | `/auth` | 登录、注册 | + +## 3. 详细接口测试文档 + +### 3.1 SQL 项目模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/list` | GET | `sql_project:list` | 查询 SQL 项目列表 | pageNo, pageSize | `{"code": 20000, "data": {...}}` | +| `/create` | POST | `sql_project:create` | 创建 SQL 项目 | 详见控制器 | `{"code": 20000, "data": {"sqlId": 1}}` | +| `/detail` | GET | `sql_project:detail` | 查询 SQL 项目详情 | id | `{"code": 20000, "data": {...}}` | +| `/delete` | POST | `sql_project:delete` | 删除 SQL 项目 | id | `{"code": 20000, "data": {"sqlId": 1}}` | +| `/execute` | POST | `sql_project:execute` | 执行 SQL 项目 | id, envId | `{"code": 20000, "data": {...}}` | + +**测试用例:** +1. 正常查询:`GET /list?pageNo=1&pageSize=10` +2. 成功创建:`POST /create` 传入完整参数 +3. 详情查询:`GET /detail?id=1` +4. 成功删除:`POST /delete` 传入 id +5. 执行 SQL:`POST /execute` 传入 id 和环境 + +### 3.2 项目管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/project/list` | GET | `project:list` | 查询项目列表 | pageNo, pageSize, productId | `{"code": 20000, "data": {"list": [...], "total": 10}}` | +| `/project/detail` | GET | `project:detail` | 查询项目详情 | id | `{"code": 20000, "data": {...}}` | +| `/project/create` | POST | `project:create` | 创建项目 | name, productId, desc | `{"code": 20000, "data": {"id": 1}}` | +| `/project/update` | POST | `project:update` | 更新项目 | id, name, desc | `{"code": 20000, "data": {"id": 1}}` | +| `/project/delete` | POST | `project:delete` | 删除项目 | id | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 列表查询:`GET /project/list?pageNo=1&pageSize=20` +2. 详情查询:`GET /project/detail?id=1` +3. 创建项目:`POST /project/create` 传入 name, productId +4. 更新项目:`POST /project/update` 传入 id, name +5. 删除项目:`POST /project/delete` 传入 id + +### 3.3 环境管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/environment/list` | GET | `environment:list` | 查询环境列表 | pageNo, pageSize, projectId | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/environment/create` | POST | `environment:create` | 创建环境 | projectId, name, url, config | `{"code": 20000, "data": {"id": 1}}` | +| `/environment/update` | POST | `environment:update` | 更新环境 | id, name, url, config | `{"code": 20000, "data": {"id": 1}}` | +| `/environment/delete` | POST | `environment:delete` | 删除环境 | id | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 环境列表:`GET /environment/list?projectId=1` +2. 创建环境:`POST /environment/create` 传入完整参数 +3. 更新环境:`POST /environment/update` 传入 id 和更新字段 +4. 删除环境:`POST /environment/delete` 传入 id + +### 3.4 项目成员模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/project/member/list` | GET | `project_member:list` | 查询项目成员列表 | project_id, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 3}}` | +| `/project/member/create` | POST | `project_member:create` | 批量添加项目成员 | project_id, user_ids | `{"code": 20000, "data": {"id": [1, 2, 3]}}` | + +**测试用例:** +1. 成员列表:`GET /project/member/list?project_id=1&pageNo=1&pageSize=10` +2. 批量添加:`POST /project/member/create` 传入 `{"project_id": 1, "user_ids": [2, 3]}` +3. 边界测试:user_ids 为空数组 +4. 错误测试:user_ids 不是数组 + +**响应示例:** +```json +{ + "code": 20000, + "data": { + "list": [ + { + "id": 1, + "project_id": 1, + "user_id": 2, + "role": 1, + "role_name": "测试经理", + "project_name": "示例项目", + "username": "zhangsan", + "real_name": "张三", + "joined_time": "2026-04-22T10:00:00" + } + ], + "total": 1 + } +} +``` + +### 3.5 产品管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/product/list` | GET | `product:list` | 查询产品列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/product/detail` | GET | `product:detail` | 查询产品详情 | id | `{"code": 20000, "data": {...}}` | +| `/product/create` | POST | `product:create` | 创建产品 | name, desc | `{"code": 20000, "data": {"id": 1}}` | +| `/product/update` | POST | `product:update` | 更新产品 | id, name, desc | `{"code": 20000, "data": {"id": 1}}` | +| `/product/delete` | POST | `product:delete` | 删除产品 | id | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 产品列表:`GET /product/list?pageNo=1&pageSize=20` +2. 详情查询:`GET /product/detail?id=1` +3. 创建产品:`POST /product/create` 传入 name +4. 更新产品:`POST /product/update` 传入 id, name +5. 删除产品:`POST /product/delete` 传入 id + +### 3.6 模块管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/module/tree` | GET | `module:list` | 获取模块树 | projectId | `{"code": 20000, "data": [...]}` | +| `/module/create` | POST | `module:create` | 创建模块 | projectId, name, parentId | `{"code": 20000, "data": {"id": 1}}` | +| `/module/update` | POST | `module:update` | 更新模块 | id, name | `{"code": 20000, "data": {"id": 1}}` | +| `/module/delete` | POST | `module:delete` | 删除模块 | id | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 模块树:`GET /module/tree?projectId=1` +2. 创建模块:`POST /module/create` 传入 projectId, name +3. 更新模块:`POST /module/update` 传入 id, name +4. 删除模块:`POST /module/delete` 传入 id + +### 3.7 用例管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/case/list` | GET | `case:list` | 查询用例列表 | moduleId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 20}}` | +| `/case/detail` | GET | `case:detail` | 查询用例详情 | id | `{"code": 20000, "data": {...}}` | +| `/case/create` | POST | `case:create` | 创建用例 | moduleId, title, steps | `{"code": 20000, "data": {"id": 1}}` | +| `/case/update` | POST | `case:update` | 更新用例 | id, title, steps | `{"code": 20000, "data": {"id": 1}}` | +| `/case/delete` | POST | `case:delete` | 删除用例 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/case/snapshot/create` | POST | `case_snapshot:create` | 创建用例快照 | caseId, reason | `{"code": 20000, "data": {"id": 1}}` | +| `/case/snapshot/list` | GET | `case_snapshot:list` | 查询用例快照 | caseId | `{"code": 20000, "data": [...]}` | +| `/case/review/create` | POST | `case_review:create` | 创建用例评审 | caseId, reviewerId | `{"code": 20000, "data": {"id": 1}}` | +| `/case/review/update` | POST | `case_review:update` | 更新评审状态 | id, status | `{"code": 20000, "data": {"id": 1}}` | +| `/case/review/list` | GET | `case_review:list` | 查询评审列表 | projectId | `{"code": 20000, "data": [...]}` | + +**测试用例:** +1. 用例列表:`GET /case/list?moduleId=1&pageNo=1&pageSize=10` +2. 详情查询:`GET /case/detail?id=1` +3. 创建用例:`POST /case/create` 传入完整参数 +4. 更新用例:`POST /case/update` 传入 id, title, steps +5. 删除用例:`POST /case/delete` 传入 id +6. 创建快照:`POST /case/snapshot/create` 传入 caseId +7. 快照列表:`GET /case/snapshot/list?caseId=1` +8. 创建评审:`POST /case/review/create` 传入 caseId, reviewerId +9. 更新评审:`POST /case/review/update` 传入 id, status +10. 评审列表:`GET /case/review/list?projectId=1` + +### 3.8 计划管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/plan/list` | GET | `plan:list` | 查询计划列表 | projectId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/plan/detail` | GET | `plan:detail` | 查询计划详情 | id | `{"code": 20000, "data": {...}}` | +| `/plan/create` | POST | `plan:create` | 创建计划 | projectId, name, startDate, endDate | `{"code": 20000, "data": {"id": 1}}` | +| `/plan/update` | POST | `plan:update` | 更新计划 | id, name, startDate, endDate | `{"code": 20000, "data": {"id": 1}}` | +| `/plan/delete` | POST | `plan:delete` | 删除计划 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/plan/round/create` | POST | `plan_round:create` | 创建轮次 | planId, name | `{"code": 20000, "data": {"id": 1}}` | +| `/plan/round/list` | GET | `plan_round:list` | 查询轮次列表 | planId | `{"code": 20000, "data": [...]}` | +| `/plan/case/add` | POST | `plan_case:add` | 添加用例到计划 | planId, caseIds | `{"code": 20000, "data": {"addedCount": 5}}` | +| `/plan/case/list` | GET | `plan_case:list` | 查询计划用例列表 | planId, roundId | `{"code": 20000, "data": [...]}` | +| `/plan/case/execute` | POST | `plan_case:execute` | 执行计划用例 | id, status, comment | `{"code": 20000, "data": {"id": 1}}` | +| `/plan/progress` | GET | `plan:progress` | 查询计划进度 | planId | `{"code": 20000, "data": {...}}` | + +**测试用例:** +1. 计划列表:`GET /plan/list?projectId=1&pageNo=1&pageSize=10` +2. 详情查询:`GET /plan/detail?id=1` +3. 创建计划:`POST /plan/create` 传入完整参数 +4. 更新计划:`POST /plan/update` 传入 id, name +5. 删除计划:`POST /plan/delete` 传入 id +6. 创建轮次:`POST /plan/round/create` 传入 planId, name +7. 轮次列表:`GET /plan/round/list?planId=1` +8. 添加用例:`POST /plan/case/add` 传入 planId, caseIds +9. 用例列表:`GET /plan/case/list?planId=1&roundId=1` +10. 执行用例:`POST /plan/case/execute` 传入 id, status +11. 进度查询:`GET /plan/progress?planId=1` + +### 3.9 报告管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/report/list` | GET | `report:list` | 查询报告列表 | projectId, pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/report/detail` | GET | `report:detail` | 查询报告详情 | id | `{"code": 20000, "data": {...}}` | +| `/report/generate` | POST | `report:generate` | 生成测试报告 | planId, roundId, title | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 报告列表:`GET /report/list?projectId=1&pageNo=1&pageSize=10` +2. 详情查询:`GET /report/detail?id=1` +3. 生成报告:`POST /report/generate` 传入 planId, roundId, title + +### 3.10 造数器模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/data/builder/list` | GET | `data_builder:list` | 查询造数器列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/data/builder/detail` | GET | `data_builder:detail` | 查询造数器详情 | id | `{"code": 20000, "data": {...}}` | +| `/data/builder/create` | POST | `data_builder:create` | 创建造数器 | name, config | `{"code": 20000, "data": {"id": 1}}` | +| `/data/builder/update` | POST | `data_builder:update` | 更新造数器 | id, name, config | `{"code": 20000, "data": {"id": 1}}` | +| `/data/builder/delete` | POST | `data_builder:delete` | 删除造数器 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/data/builder/execute` | POST | `data_builder:execute` | 执行造数器 | id, envId | `{"code": 20000, "data": {...}}` | +| `/data/task/status` | GET | `data_task:status` | 查询任务状态 | taskId | `{"code": 20000, "data": {...}}` | + +**测试用例:** +1. 造数器列表:`GET /data/builder/list?pageNo=1&pageSize=10` +2. 详情查询:`GET /data/builder/detail?id=1` +3. 创建造数器:`POST /data/builder/create` 传入 name, config +4. 更新造数器:`POST /data/builder/update` 传入 id, name, config +5. 删除造数器:`POST /data/builder/delete` 传入 id +6. 执行造数器:`POST /data/builder/execute` 传入 id, envId +7. 任务状态:`GET /data/task/status?taskId=1` + +### 3.11 权限管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/role/list` | GET | `role:list` | 查询角色列表 | - | `{"code": 20000, "data": [...]}` | +| `/role/page/list` | GET | `role:list` | 分页查询角色 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 5}}` | +| `/role/detail` | GET | `role:detail` | 查询角色详情 | id | `{"code": 20000, "data": {...}}` | +| `/role/create` | POST | `role:create` | 创建角色 | name, status | `{"code": 20000, "data": {"id": 1}}` | +| `/role/update` | POST | `role:update` | 更新角色 | id, name, status | `{"code": 20000, "data": {"id": 1}}` | +| `/role/delete` | POST | `role:delete` | 删除角色 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/permission/list` | GET | `permission:list` | 查询权限列表 | - | `{"code": 20000, "data": [...]}` | +| `/permission/detail` | GET | `permission:detail` | 查询权限详情 | id | `{"code": 20000, "data": {...}}` | +| `/permission/create` | POST | `permission:create` | 创建权限 | name, code | `{"code": 20000, "data": {"id": 1}}` | +| `/permission/update` | POST | `permission:update` | 更新权限 | id, name, code | `{"code": 20000, "data": {"id": 1}}` | +| `/permission/delete` | POST | `permission:delete` | 删除权限 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/menu/tree` | GET | `menu:list` | 获取菜单树 | - | `{"code": 20000, "data": [...]}` | +| `/menu/current/list` | GET | - | 获取当前用户菜单 | - | `{"code": 20000, "data": [...]}` | +| `/role/menu/tree` | GET | `role_menu:list` | 获取角色菜单树 | roleId | `{"code": 20000, "data": [...]}` | +| `/menu/detail` | GET | `menu:detail` | 查询菜单详情 | id | `{"code": 20000, "data": {...}}` | +| `/menu/create` | POST | `menu:create` | 创建菜单 | name, path, parentId | `{"code": 20000, "data": {"id": 1}}` | +| `/menu/update` | POST | `menu:update` | 更新菜单 | id, name, path | `{"code": 20000, "data": {"id": 1}}` | +| `/menu/delete` | POST | `menu:delete` | 删除菜单 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/role/permission/list` | GET | `role_permission:list` | 查询角色权限 | roleId | `{"code": 20000, "data": [...]}` | +| `/role/permission/assign` | POST | `role_permission:assign` | 分配角色权限 | roleId, permissionIds | `{"code": 20000, "data": {"id": 1}}` | +| `/role/menu/list` | GET | `role_menu:list` | 查询角色菜单 | roleId | `{"code": 20000, "data": [...]}` | +| `/role/menu/assign` | POST | `role_menu:assign` | 分配角色菜单 | roleId, menuIds | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 角色列表:`GET /role/list` +2. 分页角色:`GET /role/page/list?pageNo=1&pageSize=10` +3. 角色详情:`GET /role/detail?id=1` +4. 创建角色:`POST /role/create` 传入 name, status +5. 分配权限:`POST /role/permission/assign` 传入 roleId, permissionIds +6. 分配菜单:`POST /role/menu/assign` 传入 roleId, menuIds +7. 菜单树:`GET /menu/tree` +8. 当前菜单:`GET /menu/current/list` + +### 3.12 用户管理模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/user/list` | GET | `user:list` | 查询用户列表 | pageNo, pageSize | `{"code": 20000, "data": {"list": [...], "total": 10}}` | +| `/user/detail` | GET | `user:detail` | 查询用户详情 | id | `{"code": 20000, "data": {...}}` | +| `/user/create` | POST | `user:create` | 创建用户 | username, real_name, password | `{"code": 20000, "data": {"id": 1}}` | +| `/user/update` | POST | `user:update` | 更新用户 | id, real_name, password | `{"code": 20000, "data": {"id": 1}}` | +| `/user/delete` | POST | `user:delete` | 删除用户 | id | `{"code": 20000, "data": {"id": 1}}` | +| `/user/role/list` | GET | `user_role:list` | 查询用户角色 | userId | `{"code": 20000, "data": [...]}` | +| `/user/role/assign` | POST | `user_role:assign` | 分配用户角色 | userId, roleIds | `{"code": 20000, "data": {"id": 1}}` | + +**测试用例:** +1. 用户列表:`GET /user/list?pageNo=1&pageSize=20` +2. 详情查询:`GET /user/detail?id=1` +3. 创建用户:`POST /user/create` 传入 username, real_name, password +4. 更新用户:`POST /user/update` 传入 id, real_name +5. 删除用户:`POST /user/delete` 传入 id +6. 用户角色:`GET /user/role/list?userId=1` +7. 分配角色:`POST /user/role/assign` 传入 userId, roleIds + +### 3.14 认证模块 + +| 接口 | 方法 | 权限 | 描述 | 请求参数 | 响应 | +|------|------|------|------|----------|------| +| `/auth/register` | POST | - | 用户注册 | username, password, real_name | `{"code": 20000, "data": {"id": 1}}` | +| `/auth/login` | POST | - | 用户登录 | username, password | `{"code": 20000, "data": {"token": "...", "user": {...}}}` | + +**测试用例:** +1. 用户注册:`POST /auth/register` 传入 username, password, real_name +2. 用户登录:`POST /auth/login` 传入 username, password +3. 登录失败:传入错误的用户名或密码 +4. 注册失败:传入已存在的用户名 + +## 4. 认证与权限 + +### 4.1 认证方式 +所有接口(除了 `/auth/register` 和 `/auth/login`)都需要在请求头中携带 `accessToken`: + +```bash +curl -H "accessToken: your_token" http://localhost:8081/it/api/project/list +``` + +### 4.2 权限控制 +每个接口都有对应的权限标识,例如: +- `project:list` - 查看项目列表权限 +- `project:create` - 创建项目权限 +- `project:update` - 更新项目权限 +- `project:delete` - 删除项目权限 + +## 5. 响应格式 + +### 5.1 成功响应 +```json +{ + "code": 20000, + "data": {...} +} +``` + +### 5.2 失败响应 +```json +{ + "code": 40009, + "msg": "错误信息" +} +``` + +### 5.3 错误码说明 +| 错误码 | 描述 | +|--------|------| +| 40004 | 未登录或缺少token | +| 40008 | 数据库连接超时 | +| 40009 | 参数错误或业务逻辑错误 | +| 40011 | 资源不存在或查询失败 | +| 40012 | 更新/删除操作失败 | + +## 6. 测试工具与脚本 + +### 6.1 使用 curl 测试 + +**获取项目列表:** +```bash +curl 'http://localhost:8081/it/api/project/list?pageNo=1&pageSize=10' \ + -H 'accessToken: your_token' +``` + +**添加项目成员:** +```bash +curl 'http://localhost:8081/it/api/project/member/create' \ + -H 'Content-Type: application/json' \ + -H 'accessToken: your_token' \ + --data-raw '{"project_id": 1, "user_ids": [2, 3]}' +``` + +### 6.2 使用 Postman 测试 +1. 导入本测试文档 +2. 设置环境变量 `base_url` 为 `http://localhost:8081/it/api` +3. 设置环境变量 `token` 为登录后获取的 accessToken +4. 运行测试集合 + +## 7. 性能测试建议 + +1. **并发测试**:使用 JMeter 模拟 50-100 并发用户 +2. **响应时间**:目标平均响应时间 < 500ms +3. **吞吐量**:目标 QPS > 100 +4. **数据库性能**:监控 SQL 执行时间,优化慢查询 +5. **内存使用**:监控服务内存占用,避免内存泄漏 + +## 8. 安全测试建议 + +1. **认证绕过**:测试无 token 访问受保护接口 +2. **权限越权**:测试低权限用户访问高权限接口 +3. **SQL 注入**:测试输入参数中的 SQL 注入攻击 +4. **XSS 攻击**:测试输入参数中的 XSS 攻击 +5. **CSRF 攻击**:测试跨站请求伪造防护 + +## 9. 测试环境配置 + +### 9.1 本地环境 +- Python 3.8+ +- Flask 2.0+ +- MySQL 5.7+ +- Redis(可选,用于缓存) + +### 9.2 环境变量 +- `FLASK_APP`: `app.py` +- `FLASK_ENV`: `development` 或 `production` +- `DATABASE_URL`: 数据库连接字符串 +- `SECRET_KEY`: 用于 JWT 签名 + +## 10. 总结 + +本测试文档涵盖了项目所有 API 接口的测试用例,包括: +- 功能测试:验证接口正常功能 +- 边界测试:测试参数边界情况 +- 错误测试:测试错误处理 +- 性能测试:验证系统性能 +- 安全测试:验证系统安全性 + +通过系统性的测试,可以确保 API 接口的稳定性、可靠性和安全性,为前端应用提供可靠的后端支持。 \ No newline at end of file diff --git a/app/__pycache__/__init__.cpython-38.pyc b/app/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..d87475c Binary files /dev/null and b/app/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/__pycache__/__init__.cpython-38.pyc b/app/api/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..1bacb15 Binary files /dev/null and b/app/api/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/__pycache__/views.cpython-38.pyc b/app/api/__pycache__/views.cpython-38.pyc new file mode 100644 index 0000000..fb2dbbf Binary files /dev/null and b/app/api/__pycache__/views.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/__init__.cpython-38.pyc b/app/api/controller/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..21e9ebd Binary files /dev/null and b/app/api/controller/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/baseCrudController.cpython-38.pyc b/app/api/controller/__pycache__/baseCrudController.cpython-38.pyc new file mode 100644 index 0000000..d12cd0e Binary files /dev/null and b/app/api/controller/__pycache__/baseCrudController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/bugController.cpython-38.pyc b/app/api/controller/__pycache__/bugController.cpython-38.pyc new file mode 100644 index 0000000..2f37e8e Binary files /dev/null and b/app/api/controller/__pycache__/bugController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/caseController.cpython-38.pyc b/app/api/controller/__pycache__/caseController.cpython-38.pyc new file mode 100644 index 0000000..5579f8e Binary files /dev/null and b/app/api/controller/__pycache__/caseController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/dataBuilderController.cpython-38.pyc b/app/api/controller/__pycache__/dataBuilderController.cpython-38.pyc new file mode 100644 index 0000000..8494286 Binary files /dev/null and b/app/api/controller/__pycache__/dataBuilderController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/planController.cpython-38.pyc b/app/api/controller/__pycache__/planController.cpython-38.pyc new file mode 100644 index 0000000..37f9db8 Binary files /dev/null and b/app/api/controller/__pycache__/planController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/productController.cpython-38.pyc b/app/api/controller/__pycache__/productController.cpython-38.pyc new file mode 100644 index 0000000..3a6ed77 Binary files /dev/null and b/app/api/controller/__pycache__/productController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/projectController.cpython-38.pyc b/app/api/controller/__pycache__/projectController.cpython-38.pyc new file mode 100644 index 0000000..84848ab Binary files /dev/null and b/app/api/controller/__pycache__/projectController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/projectHookController.cpython-38.pyc b/app/api/controller/__pycache__/projectHookController.cpython-38.pyc new file mode 100644 index 0000000..3d15579 Binary files /dev/null and b/app/api/controller/__pycache__/projectHookController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/rbacController.cpython-38.pyc b/app/api/controller/__pycache__/rbacController.cpython-38.pyc new file mode 100644 index 0000000..8ada444 Binary files /dev/null and b/app/api/controller/__pycache__/rbacController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/reportController.cpython-38.pyc b/app/api/controller/__pycache__/reportController.cpython-38.pyc new file mode 100644 index 0000000..4263672 Binary files /dev/null and b/app/api/controller/__pycache__/reportController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/updateSqlProjectController.cpython-38.pyc b/app/api/controller/__pycache__/updateSqlProjectController.cpython-38.pyc new file mode 100644 index 0000000..e46e0da Binary files /dev/null and b/app/api/controller/__pycache__/updateSqlProjectController.cpython-38.pyc differ diff --git a/app/api/controller/__pycache__/userController.cpython-38.pyc b/app/api/controller/__pycache__/userController.cpython-38.pyc new file mode 100644 index 0000000..13bd97d Binary files /dev/null and b/app/api/controller/__pycache__/userController.cpython-38.pyc differ diff --git a/app/api/controller/baseCrudController.py b/app/api/controller/baseCrudController.py new file mode 100644 index 0000000..55790c0 --- /dev/null +++ b/app/api/controller/baseCrudController.py @@ -0,0 +1,57 @@ +# encoding: UTF-8 +from datetime import date, datetime +from decimal import Decimal + +from common.sqlSession import SqlSession + + +class BaseCrudController(object): + """通用 Controller 基类,封装公共的请求取值与序列化逻辑。""" + + def __init__(self, req_data): + # 每个 controller 持有一个独立 session,沿用当前项目的使用方式。 + self.session = SqlSession() + # req_data 兼容 request.args 和 request.get_json() 两种来源。 + self.req_data = req_data + + def close_session(self): + if self.session: + self.session.close() + + @staticmethod + def _get(req_data, *keys, default=None): + """按顺序读取多个候选参数名,兼容前后端字段别名。""" + for key in keys: + value = req_data.get(key) + if value not in (None, ''): + return value + return default + + @staticmethod + def _format_value(value): + """将数据库对象中的特殊类型转成可直接返回给前端的值。""" + if isinstance(value, datetime): + return value.strftime('%Y-%m-%d %H:%M:%S') + if isinstance(value, date): + return value.strftime('%Y-%m-%d') + if isinstance(value, Decimal): + return float(value) + return value + + @classmethod + def serialize(cls, item, exclude=None): + """单对象序列化,可按需排除不希望暴露给前端的字段。""" + if not item: + return {} + exclude = exclude or [] + item_dict = item.to_dict() + for key in exclude: + item_dict.pop(key, None) + for key, value in item_dict.items(): + item_dict[key] = cls._format_value(value) + return item_dict + + @classmethod + def serialize_list(cls, items, exclude=None): + """列表对象序列化。""" + return [cls.serialize(item, exclude) for item in items] diff --git a/app/api/controller/bugController.py b/app/api/controller/bugController.py new file mode 100644 index 0000000..a56d792 --- /dev/null +++ b/app/api/controller/bugController.py @@ -0,0 +1,325 @@ +# encoding: UTF-8 +import os +import uuid +from datetime import datetime +from flask import current_app + +from .baseCrudController import BaseCrudController +from ..model.bugModel import Bug, BugComment +from ..model.productModel import Product +from ..model.projectModel import Project +from ..model.userModel import User +from ..model.caseModel import Module +from ..service.bugService import BugService +from ..service.userService import UserService + + +class BugUploadController(BaseCrudController): + UPLOAD_FOLDER = 'attachment/bug_picture' + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp'} + + def allowed_file(self, filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in self.ALLOWED_EXTENSIONS + + def bug_upload(self): + if 'file' not in self.req_data.files: + return '', '未找到上传文件' + + file = self.req_data.files['file'] + if file.filename == '': + return '', '文件名不能为空' + + if not self.allowed_file(file.filename): + return '', '不支持的文件格式,仅支持:png, jpg, jpeg, gif, bmp' + + try: + os.makedirs(self.UPLOAD_FOLDER, exist_ok=True) + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + ext = file.filename.rsplit('.', 1)[1].lower() + new_filename = f'bug-{timestamp}-{uuid.uuid4().hex[:8]}.{ext}' + file_path = os.path.join(self.UPLOAD_FOLDER, new_filename) + file.save(file_path) + + file_url = f'/uploads/{new_filename}' + return file_url, '' + except Exception as e: + return '', f'文件上传失败:{str(e)}' + + +class BugController(BaseCrudController): + def bug_list(self): + filters = [] + product_id = self._get(self.req_data, 'productId', 'product_id') + project_id = self._get(self.req_data, 'projectId', 'project_id') + module_id = self._get(self.req_data, 'moduleId', 'module_id') + bug_type = self._get(self.req_data, 'bugType', 'bug_type') + severity = self._get(self.req_data, 'severity') + priority = self._get(self.req_data, 'priority') + status = self._get(self.req_data, 'status') + assignee_id = self._get(self.req_data, 'assigneeId', 'assignee_id') + reporter_id = self._get(self.req_data, 'reporterId', 'reporter_id') + resolved_by = self._get(self.req_data, 'resolvedBy', 'resolved_by') + reproduce_rate = self._get(self.req_data, 'reproduceRate', 'reproduce_rate') + keyword = self._get(self.req_data, 'keyword') + + if product_id: + filters.append(Bug.product_id == int(product_id)) + if project_id: + filters.append(Bug.project_id == int(project_id)) + if module_id: + filters.append(Bug.module_id == int(module_id)) + if bug_type not in (None, ''): + filters.append(Bug.bug_type == int(bug_type)) + if severity not in (None, ''): + filters.append(Bug.severity == int(severity)) + if priority not in (None, ''): + filters.append(Bug.priority == int(priority)) + if status not in (None, ''): + filters.append(Bug.status == int(status)) + if assignee_id: + filters.append(Bug.assignee_id == int(assignee_id)) + if reporter_id: + filters.append(Bug.reporter_id == int(reporter_id)) + if resolved_by: + filters.append(Bug.resolved_by == int(resolved_by)) + if reproduce_rate not in (None, ''): + filters.append(Bug.reproduce_rate == int(reproduce_rate)) + if keyword: + filters.append(Bug.title.like(f'%{keyword}%') | Bug.description.like(f'%{keyword}%')) + + items, total = BugService.list_by_filters( + self.session, Bug, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + Bug.created_time + ) + + user_ids = [] + for item in items: + if item.assignee_id: + user_ids.append(item.assignee_id) + if item.reporter_id: + user_ids.append(item.reporter_id) + if item.resolved_by: + user_ids.append(item.resolved_by) + + user_info_map = UserService.get_user_info_map(self.session, user_ids) if user_ids else {} + + result_list = [] + for item in items: + bug_dict = item.to_dict() + if item.assignee_id and item.assignee_id in user_info_map: + bug_dict['assignee_name'] = user_info_map[item.assignee_id].get('real_name', '') + else: + bug_dict['assignee_name'] = '' + if item.reporter_id and item.reporter_id in user_info_map: + bug_dict['reporter_name'] = user_info_map[item.reporter_id].get('real_name', '') + else: + bug_dict['reporter_name'] = '' + if item.resolved_by and item.resolved_by in user_info_map: + bug_dict['resolved_by_name'] = user_info_map[item.resolved_by].get('real_name', '') + else: + bug_dict['resolved_by_name'] = '' + result_list.append(bug_dict) + + return {'list': result_list, 'total': total} + + def bug_detail(self): + bug_id = self._get(self.req_data, 'bugId', 'id') + if not bug_id: + return {}, 'bugId 为必传参数' + item = BugService.get_by_id(self.session, Bug, bug_id) + if not item: + return {}, '未查询到对应 Bug!' + ret = self.serialize(item, ['is_delete']) + + if item.product_id: + product = self.session.query(Product).filter(Product.id == item.product_id, Product.is_delete == 0).first() + ret['product_name'] = product.name if product else '' + + if item.project_id: + project = self.session.query(Project).filter(Project.id == item.project_id, Project.is_delete == 0).first() + ret['project_name'] = project.name if project else '' + + if item.reporter_id: + reporter = self.session.query(User).filter(User.id == item.reporter_id, User.is_delete == 0).first() + ret['reporter_name'] = reporter.real_name if reporter else '' + + if item.assignee_id: + assignee = self.session.query(User).filter(User.id == item.assignee_id, User.is_delete == 0).first() + ret['assignee_name'] = assignee.real_name if assignee else '' + + if item.module_id: + module = self.session.query(Module).filter(Module.id == item.module_id, Module.is_delete == 0).first() + ret['module_name'] = module.name if module else '' + + if item.resolved_by: + resolved_by_user = self.session.query(User).filter(User.id == item.resolved_by, User.is_delete == 0).first() + ret['resolved_by_name'] = resolved_by_user.real_name if resolved_by_user else '' + + comments = BugService.get_comments(self.session, bug_id) + comment_user_ids = [c.user_id for c in comments if c.user_id] + user_info_map = UserService.get_user_info_map(self.session, comment_user_ids) if comment_user_ids else {} + serialized_comments = [] + for comment in comments: + comment_dict = comment.to_dict() + if comment.user_id and comment.user_id in user_info_map: + comment_dict['user_name'] = user_info_map[comment.user_id].get('real_name', '') + else: + comment_dict['user_name'] = '' + serialized_comments.append(comment_dict) + ret['comments'] = serialized_comments + + history_items = BugService.get_history(self.session, bug_id) + user_ids = set() + for h in history_items: + if h.operator_id: + user_ids.add(h.operator_id) + if h.field_name in ('assignee_id', 'reporter_id', 'user_id', 'resolved_by'): + if h.old_value: + try: + user_ids.add(int(h.old_value)) + except (ValueError, TypeError): + pass + if h.new_value: + try: + user_ids.add(int(h.new_value)) + except (ValueError, TypeError): + pass + + user_info_map = UserService.get_user_info_map(self.session, list(user_ids)) if user_ids else {} + + serialized_history = [] + for h in history_items: + h_dict = h.to_dict() + if h.operator_id: + h_dict['operator_id'] = user_info_map.get(h.operator_id, {}).get('real_name', h.operator_id) + if h.field_name in ('assignee_id', 'reporter_id', 'user_id', 'resolved_by'): + if h.old_value: + try: + old_uid = int(h.old_value) + h_dict['old_value'] = user_info_map.get(old_uid, {}).get('real_name', h.old_value) + except (ValueError, TypeError): + pass + if h.new_value: + try: + new_uid = int(h.new_value) + h_dict['new_value'] = user_info_map.get(new_uid, {}).get('real_name', h.new_value) + except (ValueError, TypeError): + pass + serialized_history.append(h_dict) + + ret['history'] = serialized_history + return ret, '' + + def bug_create(self): + title = self._get(self.req_data, 'title') + product_id = self._get(self.req_data, 'productId', 'product_id') + project_id = self._get(self.req_data, 'projectId', 'project_id') + if not title or not product_id or not project_id: + return 0, 'title、productId、projectId 为必传参数' + + bug_key = BugService.generate_bug_key(self.session) + add_info = { + 'bug_key': bug_key, + 'title': title, + 'description': self._get(self.req_data, 'description'), + 'bug_type': int(self._get(self.req_data, 'bugType', 'bug_type', default=1)), + 'severity': int(self._get(self.req_data, 'severity', default=2)), + 'priority': int(self._get(self.req_data, 'priority', default=2)), + 'status': 0, + 'reporter_id': self._get(self.req_data, 'reporterId', 'reporter_id'), + 'assignee_id': self._get(self.req_data, 'assigneeId', 'assignee_id'), + 'product_id': product_id, + 'project_id': project_id, + 'module_id': self._get(self.req_data, 'moduleId', 'module_id'), + 'case_id': self._get(self.req_data, 'caseId', 'case_id'), + 'plan_id': self._get(self.req_data, 'planId', 'plan_id'), + 'environment': self._get(self.req_data, 'environment'), + 'steps': self._get(self.req_data, 'steps'), + 'solution': self._get(self.req_data, 'solution'), + 'resolve_version': self._get(self.req_data, 'resolveVersion', 'resolve_version'), + 'resolved_by': self._get(self.req_data, 'resolvedBy', 'resolved_by'), + 'reproduce_rate': self._get(self.req_data, 'reproduceRate', 'reproduce_rate'), + 'is_delete': 0 + } + return BugService.create(self.session, Bug, add_info) + + def bug_update(self): + bug_id = self._get(self.req_data, 'bugId', 'id') + if not bug_id: + return 0, 'bugId 为必传参数' + + update_info = {} + field_mapping = [ + (('title',), 'title'), + (('description',), 'description'), + (('bugType', 'bug_type'), 'bug_type'), + (('severity',), 'severity'), + (('priority',), 'priority'), + (('status',), 'status'), + (('assigneeId', 'assignee_id'), 'assignee_id'), + (('reporterId', 'reporter_id'), 'reporter_id'), + (('moduleId', 'module_id'), 'module_id'), + (('caseId', 'case_id'), 'case_id'), + (('planId', 'plan_id'), 'plan_id'), + (('environment',), 'environment'), + (('steps',), 'steps'), + (('solution',), 'solution'), + (('resolveVersion', 'resolve_version'), 'resolve_version'), + (('resolvedBy', 'resolved_by'), 'resolved_by'), + (('reproduceRate', 'reproduce_rate'), 'reproduce_rate') + ] + + for req_keys, column_key in field_mapping: + value = self._get(self.req_data, *req_keys) + if value is not None: + update_info[column_key] = value + + result = BugService.update_by_id(self.session, Bug, bug_id, update_info) + + comment = self._get(self.req_data, 'comment') + user_id = self._get(self.req_data, 'user_id', 'userId') + if comment and user_id: + BugService.add_comment(self.session, bug_id, comment, user_id) + + return result + + def bug_delete(self): + bug_id = self._get(self.req_data, 'bugId', 'id') + if not bug_id: + return 0, 'bugId 为必传参数' + return BugService.delete_by_id(self.session, Bug, bug_id) + + def bug_history_add(self): + bug_id = self._get(self.req_data, 'bugId', 'id') + field_name = self._get(self.req_data, 'fieldName', 'field_name') + old_value = self._get(self.req_data, 'oldValue', 'old_value') + new_value = self._get(self.req_data, 'newValue', 'new_value') + operator_id = self._get(self.req_data, 'operatorId', 'operator_id', 'user_id', 'userId') + + if not bug_id: + return 0, 'bugId 为必传参数' + if not field_name: + return 0, 'fieldName 为必传参数' + if not operator_id: + return 0, 'operatorId 为必传参数' + + success = BugService.add_history(self.session, bug_id, field_name, old_value, new_value, operator_id) + return 1 if success else 0, '' if success else '添加历史记录失败' + + def bug_comment_add(self): + user_id = self._get(self.req_data, 'user_id', 'reporter_id', 'reporterId') + bug_id = self._get(self.req_data, 'bugId') + content = self._get(self.req_data, 'content') + if not bug_id: + return 0, 'bugId 为必传参数' + if not content: + return 0, 'content 为必传参数' + return BugService.add_comment(self.session, bug_id, content, user_id) + + def bug_stats(self): + product_id = self._get(self.req_data, 'productId', 'product_id') + project_id = self._get(self.req_data, 'projectId', 'project_id') + return BugService.get_stats(self.session, product_id, project_id) diff --git a/app/api/controller/caseController.py b/app/api/controller/caseController.py new file mode 100644 index 0000000..5a61878 --- /dev/null +++ b/app/api/controller/caseController.py @@ -0,0 +1,413 @@ +# encoding: UTF-8 +import os +import json + +from sqlalchemy import and_, or_ +from flask import g + +from .baseCrudController import BaseCrudController +from ..model.caseModel import CaseReview, CaseSnapshot, Module, TestCase +from ..model.projectModel import Project +from ..model.userModel import User +from ..service.caseService import CaseService +from logger import logger + + +class CaseController(BaseCrudController): + def module_list(self): + project_id = self._get(self.req_data, 'projectId') + parent_id = self._get(self.req_data, 'parentId') + filters = [] + + if project_id: + filters.append(Module.project_id == int(project_id)) + if parent_id not in (None, ''): + filters.append(Module.parent_id == int(parent_id)) + + parent_module = Module.__table__.alias('parent') + query = self.session.query(Module, parent_module.c.name.label('parent_name')).\ + outerjoin(parent_module, Module.parent_id == parent_module.c.id).\ + filter(*filters) + + if hasattr(Module, 'is_delete'): + query = query.filter(Module.is_delete == 0) + + total = query.count() + + page_num = int(self._get(self.req_data, 'pageNo', default=1)) + page_size = int(self._get(self.req_data, 'pageSize', default=200)) + + query = query.order_by(Module.id) + items = query.offset((page_num - 1) * page_size).limit(page_size).all() + + result_list = [] + for module, parent_name in items: + module_dict = self.serialize(module, ['is_delete']) + module_dict['parent_name'] = parent_name or '' + result_list.append(module_dict) + + return {'list': result_list, 'total': total} + + def module_create(self): + project_id = self._get(self.req_data, 'projectId') + name = self._get(self.req_data, 'name') + if not project_id or not name: + return 0, 'projectId、name 为必传参数' + add_info = {'project_id': project_id, 'parent_id': int(self._get(self.req_data, 'parentId', default=0)), 'name': name, 'sort_order': int(self._get(self.req_data, 'sortOrder', default=0)), 'path': self._get(self.req_data, 'path'), 'is_delete': 0} + return CaseService.create(self.session, Module, add_info) + + def module_update(self): + module_id = self._get(self.req_data, 'moduleId', 'id') + if not module_id: + return 0, 'moduleId 为必传参数' + update_info = {} + for req_key, column_key in [('parentId', 'parent_id'), ('name', 'name'), ('sortOrder', 'sort_order'), ('path', 'path')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return CaseService.update_by_id(self.session, Module, module_id, update_info) + + def module_delete(self): + module_id = self._get(self.req_data, 'moduleId', 'id') + if not module_id: + return 0, 'moduleId 为必传参数' + return CaseService.delete_by_id(self.session, Module, module_id) + + def case_list(self): + """分页查询用例列表,支持项目名称、用例标题、优先级、类型、状态、是否自动化、标签过滤。""" + filters = [] + + project_name = self._get(self.req_data, 'projectName') + if project_name: + filters.append(Project.name.like('%{}%'.format(project_name))) + + project_id = self._get(self.req_data, 'projectId') + if project_id: + filters.append(TestCase.project_id == int(project_id)) + + module_name = self._get(self.req_data, 'moduleName', 'module_name') + if module_name: + filters.append(Module.name.like('%{}%'.format(module_name))) + + for req_key, column in [('moduleId', TestCase.module_id), ('priority', TestCase.priority), + ('caseType', TestCase.case_type), ('status', TestCase.status), + ('isAuto', TestCase.is_auto)]: + value = self._get(self.req_data, req_key) + if value not in (None, ''): + filters.append(column == int(value)) + + keyword = self._get(self.req_data, 'keyword') + if keyword: + filters.append(TestCase.title.like('%{}%'.format(keyword))) + + tag = self._get(self.req_data, 'tag') + if tag: + filters.append(TestCase.tags.any(tag)) + + created_by_name = self._get(self.req_data, 'createdBy') + if created_by_name: + filters.append(User.real_name.like('%{}%'.format(created_by_name))) + + query = self.session.query(TestCase, Project.name.label('project_name'), Module.name.label('module_name'), User.real_name.label('created_by_name')).\ + join(Project, TestCase.project_id == Project.id, isouter=True).\ + join(Module, TestCase.module_id == Module.id, isouter=True).\ + join(User, TestCase.created_by == User.id, isouter=True).\ + filter(*filters) + + if hasattr(TestCase, 'is_delete'): + query = query.filter(TestCase.is_delete == 0) + if hasattr(Project, 'is_delete'): + query = query.filter(Project.is_delete == 0) + if hasattr(Module, 'is_delete'): + query = query.filter(or_(Module.is_delete == 0, Module.is_delete.is_(None))) + if hasattr(User, 'is_delete'): + query = query.filter(or_(User.is_delete == 0, User.is_delete.is_(None))) + + total = query.count() + + page_num = int(self._get(self.req_data, 'pageNo', 'page', default=1)) + page_size = int(self._get(self.req_data, 'pageSize', 'size', default=20)) + + query = query.order_by(TestCase.id.desc()) + items = query.offset((page_num - 1) * page_size).limit(page_size).all() + + result_list = [] + for case, project_name, module_name, created_by_name in items: + case_dict = self.serialize(case, ['is_delete']) + case_dict['project_name'] = project_name or '' + case_dict['module_name'] = module_name or '' + case_dict['case_key'] = case_dict.get('case_key', '') + case_dict['created_by_name'] = created_by_name or '' + if not case_dict.get('steps'): + case_dict['steps'] = '' + result_list.append(case_dict) + + return {'list': result_list, 'total': total} + + def case_detail(self): + case_id = self._get(self.req_data, 'caseId', 'id') + if not case_id: + return {}, 'caseId 为必传参数' + item = CaseService.get_by_id(self.session, TestCase, case_id) + if not item: + return {}, '未查询到对应用例!' + result = self.serialize(item, ['is_delete']) + if not result.get('steps'): + result['steps'] = '' + if item.module_id: + module = self.session.query(Module).filter(Module.id == item.module_id).first() + result['module_name'] = module.name if module else '' + else: + result['module_name'] = '' + return result, '' + + def case_create(self): + project_id = self._get(self.req_data, 'projectId') + title = self._get(self.req_data, 'title') + if not project_id or not title: + return 0, 'projectId、title 为必传参数' + steps_value = self._get(self.req_data, 'steps', default='') + if isinstance(steps_value, (list, dict)): + steps_value = '' + 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), + 'title': title, + 'preconditions': self._get(self.req_data, 'preconditions'), + 'steps': steps_value, + 'expected_results': self._get(self.req_data, 'expectedResults'), + 'priority': int(self._get(self.req_data, 'priority', default=2)), + 'case_type': int(self._get(self.req_data, 'caseType', default=1)), + 'tags': self._get(self.req_data, 'tags', default=[]), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'is_auto': int(self._get(self.req_data, 'isAuto', default=0)), + 'created_by': getattr(g, 'current_user_id', None), + 'is_delete': 0 + } + return CaseService.create(self.session, TestCase, add_info) + + def case_update(self): + """更新用例内容,只更新请求中传入的字段。""" + case_id = self._get(self.req_data, 'caseId', 'id') + if not case_id: + return 0, 'caseId 为必传参数' + update_info = {} + mapping = [('moduleId', 'module_id'), ('caseKey', 'case_key'), ('title', 'title'), ('preconditions', 'preconditions'), ('expectedResults', 'expected_results'), ('priority', 'priority'), ('caseType', 'case_type'), ('tags', 'tags'), ('status', 'status'), ('isAuto', 'is_auto')] + for req_key, column_key in mapping: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + steps_value = self._get(self.req_data, 'steps') + if steps_value is not None: + if isinstance(steps_value, (list, dict)): + steps_value = '' + update_info['steps'] = steps_value + return CaseService.update_by_id(self.session, TestCase, case_id, update_info) + + def case_delete(self): + case_id = self._get(self.req_data, 'caseId', 'id') + if not case_id: + return 0, 'caseId 为必传参数' + return CaseService.delete_by_id(self.session, TestCase, case_id) + + def snapshot_create(self): + case_id = self._get(self.req_data, 'caseId') + if not case_id: + return 0, 'caseId 为必传参数' + case_obj = CaseService.get_by_id(self.session, TestCase, case_id) + if not case_obj: + return 0, '未查询到对应用例!' + version = CaseService.next_snapshot_version(self.session, case_id) + snapshot = self.serialize(case_obj, ['is_delete']) + if not snapshot.get('steps'): + snapshot['steps'] = '' + add_info = {'case_id': case_id, 'version': version, 'snapshot': snapshot, 'created_by': self._get(self.req_data, 'createdBy')} + return CaseService.create(self.session, CaseSnapshot, add_info) + + def snapshot_list(self): + """查询指定用例的快照历史。""" + case_id = self._get(self.req_data, 'caseId') + filters = [CaseSnapshot.case_id == int(case_id)] if case_id else [] + items, total = CaseService.list_by_filters(self.session, CaseSnapshot, filters, self._get(self.req_data, 'pageNo', default=1), self._get(self.req_data, 'pageSize', default=20), CaseSnapshot.created_time) + return {'list': self.serialize_list(items), 'total': total} + + def review_create(self): + case_id = self._get(self.req_data, 'caseId') + reviewer_id = self._get(self.req_data, 'reviewerId') + if not case_id or not reviewer_id: + return 0, 'caseId、reviewerId 为必传参数' + return CaseService.create(self.session, CaseReview, {'case_id': case_id, 'reviewer_id': reviewer_id, 'comments': self._get(self.req_data, 'comments')}) + + def review_update(self): + review_id = self._get(self.req_data, 'reviewId', 'id') + if not review_id: + return 0, 'reviewId 为必传参数' + update_info = {} + for req_key, column_key in [('status', 'status'), ('comments', 'comments'), ('diffContent', 'diff_content'), ('reviewedTime', 'reviewed_time')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return CaseService.update_by_id(self.session, CaseReview, review_id, update_info, soft_delete=False) + + def review_list(self): + """查询用例评审记录列表。""" + case_id = self._get(self.req_data, 'caseId') + filters = [CaseReview.case_id == int(case_id)] if case_id else [] + items, total = CaseService.list_by_filters(self.session, CaseReview, filters, self._get(self.req_data, 'pageNo', default=1), self._get(self.req_data, 'pageSize', default=20), CaseReview.created_time) + return {'list': self.serialize_list(items), 'total': total} + + def case_import(self, file_path, project_id): + """批量导入用例""" + try: + from openpyxl import load_workbook + except ImportError: + return 0, '请先安装 openpyxl 依赖' + + if not os.path.exists(file_path): + return 0, '文件不存在' + + if not project_id: + return 0, 'projectId 为必传参数' + + wb = load_workbook(file_path) + sheet = wb.active + + headers = {} + for col in range(1, sheet.max_column + 1): + header = str(sheet.cell(row=1, column=col).value).strip() if sheet.cell(row=1, column=col).value else '' + if header: + headers[header] = col + + required_columns = ['所属模块', '用例标题', '前置条件', '步骤', '预期', '关键词', '优先级', '用例类型'] + for col in required_columns: + if col not in headers: + return 0, f'缺少必要列: {col}' + + module_name_to_id = {} + existing_modules = self.session.query(Module).filter(Module.project_id == int(project_id), Module.is_delete == 0).all() + for module in existing_modules: + module_name_to_id[module.name] = module.id + + success_count = 0 + fail_count = 0 + fail_messages = [] + + for row in range(2, sheet.max_row + 1): + try: + module_path_str = str(sheet.cell(row=row, column=headers['所属模块']).value).strip() if sheet.cell(row=row, column=headers['所属模块']).value else '' + + if not module_path_str: + fail_count += 1 + fail_messages.append(f'第{row}行:所属模块为空') + continue + + module_names = [m.strip() for m in module_path_str.split('/') if m.strip()] + + if not module_names: + fail_count += 1 + fail_messages.append(f'第{row}行:所属模块格式不正确') + continue + + parent_id = 0 + module_id = None + + for idx, module_name in enumerate(module_names): + if module_name in module_name_to_id: + parent_id = module_name_to_id[module_name] + else: + path_parts = module_names[:idx+1] + module_path = '/' + '/'.join(path_parts) + new_module = Module( + project_id=int(project_id), + parent_id=parent_id, + name=module_name, + path=module_path, + is_delete=0 + ) + self.session.add(new_module) + self.session.flush() + module_name_to_id[module_name] = new_module.id + parent_id = new_module.id + + module_id = parent_id + + title = str(sheet.cell(row=row, column=headers['用例标题']).value).strip() if sheet.cell(row=row, column=headers['用例标题']).value else '' + preconditions = str(sheet.cell(row=row, column=headers['前置条件']).value).strip() if sheet.cell(row=row, column=headers['前置条件']).value else '' + steps = str(sheet.cell(row=row, column=headers['步骤']).value).strip() if sheet.cell(row=row, column=headers['步骤']).value else '' + expected_results = str(sheet.cell(row=row, column=headers['预期']).value).strip() if sheet.cell(row=row, column=headers['预期']).value else '' + keywords = str(sheet.cell(row=row, column=headers['关键词']).value).strip() if sheet.cell(row=row, column=headers['关键词']).value else '' + priority_str = str(sheet.cell(row=row, column=headers['优先级']).value).strip() if sheet.cell(row=row, column=headers['优先级']).value else '2' + case_type_str = str(sheet.cell(row=row, column=headers['用例类型']).value).strip() if sheet.cell(row=row, column=headers['用例类型']).value else '1' + + if not title: + fail_count += 1 + fail_messages.append(f'第{row}行:用例标题为空') + continue + + priority_map = {'P0': 0, 'P1': 1, 'P2': 2, 'P3': 3} + priority = priority_map.get(priority_str, int(priority_str) if priority_str.isdigit() else 2) + + case_type_map = {'功能': 1, '性能': 2, '安全': 3, '接口': 4} + case_type = case_type_map.get(case_type_str, int(case_type_str) if case_type_str.isdigit() else 1) + + tags = [k.strip() for k in keywords.split(',')] if keywords else [] + + retry_count = 0 + max_retries = 5 + case_key = CaseService.next_case_key(self.session, project_id) + + while retry_count < max_retries: + try: + case = TestCase( + project_id=int(project_id), + module_id=module_id, + case_key=case_key, + title=title, + preconditions=preconditions, + steps=steps, + expected_results=expected_results, + priority=priority, + case_type=case_type, + tags=tags, + status=1, + is_auto=0, + created_by=getattr(g, 'current_user_id', None), + is_delete=0 + ) + self.session.add(case) + self.session.flush() + success_count += 1 + break + except Exception as e: + if 'duplicate key' in str(e).lower() or 'already exists' in str(e).lower(): + logger.warning(f'case_import case_key冲突,重新生成:{case_key}, 错误:{str(e)}') + case_key = CaseService.next_case_key(self.session, project_id) + retry_count += 1 + else: + raise + + if retry_count >= max_retries: + fail_count += 1 + fail_messages.append(f'第{row}行:用例标题[{title}]导入失败:case_key生成失败') + + except Exception as e: + fail_count += 1 + fail_messages.append(f'第{row}行:导入失败 - {str(e)}') + + try: + self.session.commit() + msg = f'导入完成:成功{success_count}条,失败{fail_count}条' + if fail_messages: + msg += f'。失败详情:{"; ".join(fail_messages[:10])}' + if len(fail_messages) > 10: + msg += f'...(共{len(fail_messages)}条)' + return success_count, msg + except Exception as e: + self.session.rollback() + return 0, f'提交失败:{str(e)}' + + @staticmethod + def get_template_path(): + """获取模板文件路径""" + return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), 'attachment', '用例导入模版.xlsx') diff --git a/app/api/controller/dataBuilderController.py b/app/api/controller/dataBuilderController.py new file mode 100644 index 0000000..bbafb75 --- /dev/null +++ b/app/api/controller/dataBuilderController.py @@ -0,0 +1,80 @@ +# encoding: UTF-8 +from .baseCrudController import BaseCrudController +from ..model.dataBuilderModel import DataBuilder, DataTask +from ..service.dataBuilderService import DataBuilderService + + +class DataBuilderController(BaseCrudController): + """造数器与造数任务相关接口控制器。""" + + def builder_list(self): + """分页查询造数器列表,可按项目过滤。""" + filters = [] + project_id = self._get(self.req_data, 'projectId') + if project_id: + filters.append(DataBuilder.project_id == int(project_id)) + items, total = DataBuilderService.list_by_filters(self.session, DataBuilder, filters, + self._get(self.req_data, 'pageNo', default=1), + self._get(self.req_data, 'pageSize', default=20), + DataBuilder.created_time) + return {'list': self.serialize_list(items, ['is_delete']), 'total': total} + + def builder_detail(self): + """查询造数器详情。""" + builder_id = self._get(self.req_data, 'builderId', 'id') + if not builder_id: + return {}, 'builderId 为必传参数' + item = DataBuilderService.get_by_id(self.session, DataBuilder, builder_id) + if not item: + return {}, '未查询到对应造数器!' + return self.serialize(item, ['is_delete']), '' + + def builder_create(self): + """创建造数器,definition 保存流程编排或模板定义。""" + project_id = self._get(self.req_data, 'projectId') + name = self._get(self.req_data, 'name') + definition = self._get(self.req_data, 'definition') + if not project_id or not name or definition is None: + return 0, 'projectId、name、definition 为必传参数' + add_info = {'project_id': project_id, 'name': name, 'description': self._get(self.req_data, 'description'), + 'builder_type': int(self._get(self.req_data, 'builderType', default=1)), 'definition': definition, + 'input_schema': self._get(self.req_data, 'inputSchema'), + 'output_example': self._get(self.req_data, 'outputExample'), + 'created_by': self._get(self.req_data, 'createdBy'), 'is_delete': 0} + return DataBuilderService.create(self.session, DataBuilder, add_info) + + def builder_update(self): + builder_id = self._get(self.req_data, 'builderId', 'id') + if not builder_id: + return 0, 'builderId 为必传参数' + update_info = {} + for req_key, column_key in [('name', 'name'), ('description', 'description'), ('builderType', 'builder_type'), + ('definition', 'definition'), ('inputSchema', 'input_schema'), + ('outputExample', 'output_example')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return DataBuilderService.update_by_id(self.session, DataBuilder, builder_id, update_info) + + def builder_delete(self): + builder_id = self._get(self.req_data, 'builderId', 'id') + if not builder_id: + return 0, 'builderId 为必传参数' + return DataBuilderService.delete_by_id(self.session, DataBuilder, builder_id) + + def builder_execute(self): + builder_id = self._get(self.req_data, 'builderId') + if not builder_id: + return {}, 'builderId 为必传参数' + return DataBuilderService.execute_builder(self.session, builder_id, + self._get(self.req_data, 'params', default={}), + self._get(self.req_data, 'createdBy')) + + def task_status(self): + task_id = self._get(self.req_data, 'taskId') + if not task_id: + return {}, 'taskId 为必传参数' + item = DataBuilderService.get_by_id(self.session, DataTask, task_id, soft_delete=False) + if not item: + return {}, '未查询到对应任务!' + return self.serialize(item), '' diff --git a/app/api/controller/planController.py b/app/api/controller/planController.py new file mode 100644 index 0000000..a866284 --- /dev/null +++ b/app/api/controller/planController.py @@ -0,0 +1,192 @@ +# encoding: UTF-8 +from datetime import datetime + +from .baseCrudController import BaseCrudController +from ..model.planModel import PlanCase, TestPlan, TestRound +from ..model.caseModel import Module, TestCase +from ..service.planService import PlanService +from ..service.userService import UserService + + +class PlanController(BaseCrudController): + def plan_list(self): + filters = [] + project_id = self._get(self.req_data, 'projectId', 'project_id') + status = self._get(self.req_data, 'status') + keyword = self._get(self.req_data, 'keyword') + owner_id = self._get(self.req_data, 'ownerId', 'owner_id', 'owner') + if project_id: + filters.append(TestPlan.project_id == int(project_id)) + if status not in (None, ''): + filters.append(TestPlan.status == int(status)) + if keyword: + filters.append(TestPlan.name.like('%{}%'.format(keyword))) + if owner_id: + filters.append(TestPlan.owner_id == int(owner_id)) + + items, total = PlanService.list_by_filters(self.session, TestPlan, filters, self._get(self.req_data, 'pageNo', 'page', default=1), self._get(self.req_data, 'pageSize', 'size', default=20), TestPlan.created_time) + + owner_ids = [item.owner_id for item in items if item.owner_id] + user_info_map = UserService.get_user_info_map(self.session, owner_ids) if owner_ids else {} + + result_list = [] + for item in items: + plan_dict = item.to_dict() + if item.owner_id and item.owner_id in user_info_map: + plan_dict['owner_name'] = user_info_map[item.owner_id].get('real_name', '') + else: + plan_dict['owner_name'] = '' + result_list.append(plan_dict) + + return {'list': result_list, 'total': total} + + def plan_detail(self): + plan_id = self._get(self.req_data, 'planId', 'id') + if not plan_id: + return {}, 'planId 为必传参数' + item = PlanService.get_by_id(self.session, TestPlan, plan_id) + if not item: + return {}, '未查询到对应计划!' + ret = self.serialize(item, ['is_delete']) + ret.update(PlanService.plan_stats(self.session, plan_id)) + return ret, '' + + def plan_create(self): + project_id = self._get(self.req_data, 'projectId', 'project_id') + 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} + return PlanService.create(self.session, TestPlan, add_info) + + def plan_update(self): + """更新测试计划,只更新请求中传入的字段。""" + plan_id = self._get(self.req_data, 'planId', 'id') + 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')]: + value = self._get(self.req_data, *req_keys) + if value is not None: + update_info[column_key] = value + return PlanService.update_by_id(self.session, TestPlan, plan_id, update_info) + + def plan_delete(self): + plan_id = self._get(self.req_data, 'planId', 'id') + if not plan_id: + return 0, 'planId 为必传参数' + return PlanService.delete_by_id(self.session, TestPlan, plan_id) + + def round_create(self): + plan_id = self._get(self.req_data, 'planId') + round_no = self._get(self.req_data, 'roundNo') + if not plan_id or not round_no: + return 0, 'planId、roundNo 为必传参数' + return PlanService.create(self.session, TestRound, {'plan_id': plan_id, 'round_no': round_no, 'name': self._get(self.req_data, 'name'), 'start_date': self._get(self.req_data, 'startDate'), 'end_date': self._get(self.req_data, 'endDate')}) + + def round_list(self): + plan_id = self._get(self.req_data, 'planId') + filters = [TestRound.plan_id == int(plan_id)] if plan_id else [] + items, total = PlanService.list_by_filters(self.session, TestRound, filters, self._get(self.req_data, 'pageNo', default=1), self._get(self.req_data, 'pageSize', default=50), TestRound.id) + return {'list': self.serialize_list(items), 'total': total} + + def plan_case_add(self): + plan_id = self._get(self.req_data, 'planId') + case_ids = self._get(self.req_data, 'caseIds', default=[]) + if not plan_id or not case_ids: + return 0, 'planId、caseIds 为必传参数' + batch_info_list = [{'plan_id': plan_id, 'case_id': case_id, 'assignee_id': self._get(self.req_data, 'assigneeId'), 'round_no': int(self._get(self.req_data, 'roundNo', default=1)), 'status': 0} for case_id in case_ids] + return PlanService.batch_create(self.session, PlanCase, batch_info_list) + + def plan_case_list(self): + plan_id = self._get(self.req_data, 'planId') + 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, ''): + filters.append(PlanCase.round_no == int(round_no)) + items, total = PlanService.list_by_filters(self.session, PlanCase, filters, self._get(self.req_data, 'pageNo', default=1), self._get(self.req_data, 'pageSize', default=20), PlanCase.id, asc=True) + + case_ids = [item.case_id for item in items if item.case_id] + case_info_map = {} + module_info_map = {} + if case_ids: + cases = self.session.query(TestCase).filter(TestCase.id.in_(case_ids), TestCase.is_delete == 0).all() + case_info_map = {case.id: {'case_key': case.case_key, 'title': case.title, 'module_id': case.module_id} for case in cases} + + module_ids = [case.module_id for case in cases if case.module_id] + if module_ids: + modules = self.session.query(Module).filter(Module.id.in_(module_ids), Module.is_delete == 0).all() + module_info_map = {module.id: module.name for module in modules} + + result_list = [] + for item in items: + case_dict = item.to_dict() + if item.case_id and item.case_id in case_info_map: + case_dict['case_key'] = case_info_map[item.case_id]['case_key'] + case_dict['case_title'] = case_info_map[item.case_id]['title'] + module_id = case_info_map[item.case_id].get('module_id') + if module_id and module_id in module_info_map: + case_dict['module_name'] = module_info_map[module_id] + else: + case_dict['module_name'] = '' + else: + case_dict['case_key'] = '' + case_dict['case_title'] = '' + case_dict['module_name'] = '' + result_list.append(case_dict) + + return {'list': result_list, 'total': total} + + def plan_case_execute(self): + plan_case_id = self._get(self.req_data, 'planCaseId', 'id') + if not plan_case_id: + return 0, 'planCaseId 为必传参数' + + plan_case = PlanService.get_by_id(self.session, PlanCase, plan_case_id, soft_delete=False) + if not plan_case: + return 0, '未查询到对应计划用例!' + + plan_id = plan_case.plan_id + + 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) + + 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): + """查询计划进度统计。""" + plan_id = self._get(self.req_data, 'planId', 'plan_id') + if not plan_id: + return {}, 'planId 为必传参数' + return PlanService.plan_stats(self.session, plan_id), '' diff --git a/app/api/controller/productController.py b/app/api/controller/productController.py new file mode 100644 index 0000000..678e726 --- /dev/null +++ b/app/api/controller/productController.py @@ -0,0 +1,64 @@ +# encoding: UTF-8 +import random + +from .baseCrudController import BaseCrudController +from ..model.productModel import Product +from ..service.productService import ProductService + + +class ProductController(BaseCrudController): + """产品相关接口控制器。""" + + def product_list(self): + filters = [] + keyword = self._get(self.req_data, 'keyword') + status = self._get(self.req_data, 'status') + if keyword: + filters.append(Product.name.like('%{}%'.format(keyword))) + if status not in (None, ''): + filters.append(Product.status == int(status)) + items, total = ProductService.list_by_filters(self.session, Product, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + Product.created_time) + return {'list': self.serialize_list(items, ['is_delete']), 'total': total} + + def product_detail(self): + product_id = self._get(self.req_data, 'productId', 'id') + if not product_id: + return {}, 'productId 为必传参数' + item = ProductService.get_by_id(self.session, Product, product_id) + if not item: + return {}, '未查询到对应产品!' + return self.serialize(item, ['is_delete']), '' + + def product_create(self): + name = self._get(self.req_data, 'name') + if not name: + return 0, 'name 为必传参数' + add_info = { + 'name': name, + 'code': str(random.randint(100000, 999999)), + 'description': self._get(self.req_data, 'description'), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'is_delete': 0 + } + return ProductService.create(self.session, Product, add_info) + + def product_update(self): + product_id = self._get(self.req_data, 'productId', 'id') + if not product_id: + return 0, 'productId 为必传参数' + update_info = {} + for req_key, column_key in [('name', 'name'), ('code', 'code'), ('description', 'description'), + ('status', 'status')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return ProductService.update_by_id(self.session, Product, product_id, update_info) + + def product_delete(self): + product_id = self._get(self.req_data, 'productId', 'id') + if not product_id: + return 0, 'productId 为必传参数' + return ProductService.delete_by_id(self.session, Product, product_id) diff --git a/app/api/controller/projectController.py b/app/api/controller/projectController.py new file mode 100644 index 0000000..06179f3 --- /dev/null +++ b/app/api/controller/projectController.py @@ -0,0 +1,198 @@ +# encoding: UTF-8 +import random +import string + +from .baseCrudController import BaseCrudController +from ..model.projectModel import Environment, Project, ProjectMember +from ..service.projectService import ProjectService +from ..service.userService import UserService +from ..dao.rbacDao import RbacDao + + +class ProjectController(BaseCrudController): + """项目、项目成员、环境配置相关接口控制器。""" + + def project_list(self): + """分页查询项目列表。""" + page_num = self._get(self.req_data, 'pageNo', 'page', default=1) + page_size = self._get(self.req_data, 'pageSize', 'size', default=20) + keyword = self._get(self.req_data, 'keyword') + status = self._get(self.req_data, 'status') + filter_list = [] + # 关键字先按项目名称模糊匹配。 + if keyword: + filter_list.append(Project.name.like('%{}%'.format(keyword))) + # 状态字段是枚举数字,查询时显式转 int。 + if status not in (None, ''): + filter_list.append(Project.status == int(status)) + items, total = ProjectService.list_by_filters(self.session, Project, filter_list, page_num, page_size, + Project.created_time) + product_ids = list({item.product_id for item in items if item.product_id}) + product_map = ProjectService.get_product_map(self.session, product_ids) + result_list = self.serialize_list(items, ['is_delete']) + for item in result_list: + item['product_name'] = product_map.get(item.get('product_id'), '') + return {'list': result_list, 'total': total} + + def project_detail(self): + """查询项目详情。""" + project_id = self._get(self.req_data, 'projectId', 'id') + if not project_id: + return {}, 'projectId 为必传参数' + item = ProjectService.get_by_id(self.session, Project, project_id) + if not item: + return {}, '未查询到对应项目!' + return self.serialize(item, ['is_delete']), '' + + def project_create(self): + """创建项目。""" + name = self._get(self.req_data, 'name') + if not name: + return 0, 'name 为必传参数' + add_info = { + 'key': ''.join(random.choices(string.ascii_letters + string.digits, k=6)), + 'name': name, + 'product_id': self._get(self.req_data, 'productId', 'product_id'), + 'description': self._get(self.req_data, 'description'), + 'department': self._get(self.req_data, 'department'), + # 默认状态为启用。 + 'status': int(self._get(self.req_data, 'status', default=1)), + 'config': self._get(self.req_data, 'config', default={}), + 'created_by': self._get(self.req_data, 'createdBy'), + 'is_delete': 0 + } + return ProjectService.create(self.session, Project, add_info) + + def project_update(self): + """更新项目。""" + project_id = self._get(self.req_data, 'projectId', 'id') + if not project_id: + return 0, 'projectId 为必传参数' + update_info = {} + # 仅更新前端实际传入的字段,避免把未传字段覆盖为空。 + for req_key, column_key in [('key', 'key'), ('name', 'name'), ('productId', 'product_id'), + ('product_id', 'product_id'), ('description', 'description'), + ('department', 'department'), ('status', 'status'), ('config', 'config')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return ProjectService.update_by_id(self.session, Project, project_id, update_info) + + def project_delete(self): + """软删除项目。""" + project_id = self._get(self.req_data, 'projectId', 'id') + if not project_id: + return 0, 'projectId 为必传参数' + return ProjectService.delete_by_id(self.session, Project, project_id) + + def environment_list(self): + """按项目查询环境配置列表。""" + project_id = self._get(self.req_data, 'projectId', 'project_id') + if not project_id: + return {'list': [], 'total': 0} + items, total = ProjectService.list_by_filters(self.session, Environment, + [Environment.project_id == int(project_id)], + self._get(self.req_data, 'pageNo', default=1), + self._get(self.req_data, 'pageSize', default=20), + Environment.created_time) + return {'list': self.serialize_list(items, ['is_delete']), 'total': total} + + def environment_create(self): + """新增环境配置。""" + project_id = self._get(self.req_data, 'project_id') + name = self._get(self.req_data, 'name') + variables = self._get(self.req_data, 'variables') + if not project_id or not name or variables is None: + return 0, 'projectId、name、variables 为必传参数' + return ProjectService.create(self.session, Environment, { + 'project_id': project_id, + 'name': name, + 'variables': variables, + # 兼容是否加密开关。 + 'is_encrypted': bool(self._get(self.req_data, 'isEncrypted', default=False)), + 'is_delete': 0 + }) + + def environment_update(self): + """更新环境配置。""" + env_id = self._get(self.req_data, 'environmentId', 'id') + if not env_id: + return 0, 'environmentId 为必传参数' + update_info = {} + for req_key, column_key in [('name', 'name'), ('variables', 'variables'), ('isEncrypted', 'is_encrypted')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return ProjectService.update_by_id(self.session, Environment, env_id, update_info) + + def environment_delete(self): + """软删除环境配置。""" + env_id = self._get(self.req_data, 'environmentId', 'id') + if not env_id: + return 0, 'environmentId 为必传参数' + return ProjectService.delete_by_id(self.session, Environment, env_id) + + def member_list(self): + """查询项目成员列表(带用户名、角色名称、项目名称)。""" + project_id = self._get(self.req_data, 'projectId', 'project_id') + filters = [ProjectMember.project_id == int(project_id)] if project_id else [] + items, total = ProjectService.list_by_filters(self.session, ProjectMember, filters, + self._get(self.req_data, 'pageNo', default=1), + self._get(self.req_data, 'pageSize', default=20), + ProjectMember.joined_time) + result_list = self.serialize_list(items) + if not result_list: + return {'list': result_list, 'total': total} + user_ids = [item.get('user_id') for item in result_list] + project_ids = [item.get('project_id') for item in result_list] + user_map = UserService.get_user_info_map(self.session, user_ids) + project_map = ProjectService.get_project_name_map(self.session, project_ids) + user_role_map = UserService.get_user_roles_map(self.session, user_ids) + + for item in result_list: + user_id = item.get('user_id') + user_info = user_map.get(user_id, {}) + item['real_name'] = user_info.get('real_name', '') + item['username'] = user_info.get('username', '') + project_info = project_map.get(item.get('project_id'), {}) + item['project_name'] = project_info.get('name', '') + + role_info = user_role_map.get(user_id, {}) + role_names = role_info.get('role_names', []) + item['role_names'] = role_names + item['role_name'] = ','.join(role_names) if role_names else '' + return {'list': result_list, 'total': total} + + def member_create(self): + """批量新增项目成员(根据用户系统角色自动映射项目成员角色)。""" + project_id = self._get(self.req_data, 'project_id') + user_ids = self._get(self.req_data, 'user_ids') + if not project_id or not user_ids: + return 0, 'project_id、user_ids 为必传参数' + if not isinstance(user_ids, list): + return 0, 'user_ids 必须为数组' + if not user_ids: + return 0, 'user_ids 不能为空' + user_role_map = UserService.get_user_roles_map(self.session, user_ids) + role_name_map = RbacDao.get_role_name_map(self.session) + name_to_project_role = {name: role_id for role_id, name in role_name_map.items()} + created_ids = [] + for user_id in user_ids: + role_info = user_role_map.get(user_id, {}) + role_names = role_info.get('role_names', []) + project_role = 0 + for role_name in role_names: + if role_name in name_to_project_role: + project_role = name_to_project_role[role_name] + break + if project_role == 0: + return 0, f'用户 {user_id} 未分配有效角色,无法添加为项目成员' + create_id, err_msg = ProjectService.create(self.session, ProjectMember, { + 'project_id': project_id, + 'user_id': user_id, + 'role': project_role + }) + if err_msg: + return 0, f'用户 {user_id} 添加失败:{err_msg}' + created_ids.append(create_id) + return created_ids[0] if len(created_ids) == 1 else created_ids, '' diff --git a/app/api/controller/projectHookController.py b/app/api/controller/projectHookController.py new file mode 100644 index 0000000..171d182 --- /dev/null +++ b/app/api/controller/projectHookController.py @@ -0,0 +1,225 @@ +# encoding: UTF-8 +import time +import hmac +import hashlib +import base64 +import requests + +from .baseCrudController import BaseCrudController +from ..model.projectHookModel import ProjectHook +from ..service.projectHookService import ProjectHookService + + +class ProjectHookController(BaseCrudController): + def hook_list(self): + filters = [] + project_id = self._get(self.req_data, 'projectId', 'project_id') + hook_type = self._get(self.req_data, 'hookType', 'hook_type') + + if project_id: + filters.append(ProjectHook.project_id == int(project_id)) + if hook_type not in (None, ''): + filters.append(ProjectHook.hook_type == int(hook_type)) + + items, total = ProjectHookService.list_by_filters( + self.session, ProjectHook, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + ProjectHook.created_time + ) + + result_list = [] + hook_type_map = {1: '飞书', 2: '钉钉', 3: '企微'} + for item in items: + hook_dict = item.to_dict() + hook_dict['hook_type_name'] = hook_type_map.get(item.hook_type, '') + result_list.append(hook_dict) + + return {'list': result_list, 'total': total} + + def hook_detail(self): + hook_id = self._get(self.req_data, 'hookId', 'id') + if not hook_id: + return {}, 'hookId 为必传参数' + item = ProjectHookService.get_by_id(self.session, ProjectHook, hook_id) + if not item: + return {}, '未查询到对应Hook配置!' + ret = item.to_dict() + hook_type_map = {1: '飞书', 2: '钉钉', 3: '企微'} + ret['hook_type_name'] = hook_type_map.get(item.hook_type, '') + return ret, '' + + def hook_create(self): + project_id = self._get(self.req_data, 'projectId', 'project_id') + hook_type = self._get(self.req_data, 'hookType', 'hook_type') + webhook_url = self._get(self.req_data, 'webhookUrl', 'webhook_url') + + if not project_id: + return 0, 'projectId 为必传参数' + if not hook_type: + return 0, 'hookType 为必传参数' + if not webhook_url: + return 0, 'webhookUrl 为必传参数' + + add_info = { + 'project_id': project_id, + 'hook_type': int(hook_type), + 'webhook_url': webhook_url, + 'secret': self._get(self.req_data, 'secret'), + 'enabled': int(self._get(self.req_data, 'enabled', default=1)), + 'description': self._get(self.req_data, 'description'), + 'config': self._get(self.req_data, 'config', default={}), + 'is_delete': 0 + } + return ProjectHookService.create(self.session, ProjectHook, add_info) + + def hook_update(self): + hook_id = self._get(self.req_data, 'hookId', 'id') + if not hook_id: + return 0, 'hookId 为必传参数' + + update_info = {} + field_mapping = [ + (('hookType', 'hook_type'), 'hook_type'), + (('webhookUrl', 'webhook_url'), 'webhook_url'), + (('secret',), 'secret'), + (('enabled',), 'enabled'), + (('description',), 'description'), + (('config',), 'config') + ] + + for req_keys, column_key in field_mapping: + value = self._get(self.req_data, *req_keys) + if value is not None: + update_info[column_key] = value + + return ProjectHookService.update_by_id(self.session, ProjectHook, hook_id, update_info) + + def hook_delete(self): + hook_id = self._get(self.req_data, 'hookId', 'id') + if not hook_id: + return 0, 'hookId 为必传参数' + return ProjectHookService.delete_by_id(self.session, ProjectHook, hook_id) + + def hook_send(self): + project_id = self._get(self.req_data, 'projectId', 'project_id') + title = self._get(self.req_data, 'title') + content = self._get(self.req_data, 'content') + hook_type = self._get(self.req_data, 'hookType', 'hook_type') + hook_id = self._get(self.req_data, 'hookId', 'id') + real_name = self._get(self.req_data, 'real_name', 'realName') + + if not project_id: + return 0, 'projectId 为必传参数' + if not title: + return 0, 'title 为必传参数' + if not content: + return 0, 'content 为必传参数' + + at_prefix = f'@{real_name} ' if real_name else '' + final_content = f'{at_prefix}{content}' + + if hook_id: + hook = ProjectHookService.get_by_id(self.session, ProjectHook, hook_id) + if not hook or hook.is_delete == 1 or hook.enabled != 1: + return 0, '未找到对应的Hook或Hook未启用' + hooks = [hook] + else: + hooks = ProjectHookService.get_hooks_by_project(self.session, project_id, hook_type) + if not hooks: + return 0, '未配置对应的Hook' + + results = [] + for hook in hooks: + if hook.hook_type == 1: + success, err_msg = self._send_feishu_message(hook.webhook_url, hook.secret, title, final_content) + elif hook.hook_type == 2: + success, err_msg = self._send_dingtalk_message(hook.webhook_url, hook.secret, title, final_content) + elif hook.hook_type == 3: + success, err_msg = self._send_wecom_message(hook.webhook_url, hook.secret, title, final_content) + else: + success, err_msg = False, '未知Hook类型' + + results.append({ + 'hook_id': hook.id, + 'hook_type': hook.hook_type, + 'success': success, + 'error': err_msg + }) + + all_success = all(r['success'] for r in results) + return 1 if all_success else 0, results + + def _send_feishu_message(self, webhook_url, secret, title, content): + timestamp = str(int(time.time())) + sign = '' + if secret: + string_to_sign = f'{timestamp}\n{secret}' + hmac_code = hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha256).digest() + sign = base64.b64encode(hmac_code).decode('utf-8') + + separator = '&' if '?' in webhook_url else '?' + url = f'{webhook_url}{separator}timestamp={timestamp}&sign={sign}' if sign else webhook_url + + payload = { + 'msg_type': 'text', + 'content': { + 'text': f'【{title}】\n\n{content}' + } + } + + try: + response = requests.post(url, json=payload, timeout=10) + result = response.json() + if result.get('code') == 0: + return True, '' + else: + return False, result.get('msg', '发送失败') + except Exception as e: + return False, str(e) + + def _send_dingtalk_message(self, webhook_url, secret, title, content): + timestamp = str(int(time.time() * 1000)) + sign = '' + if secret: + string_to_sign = f'{timestamp}\n{secret}' + hmac_code = hmac.new(secret.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha256).digest() + sign = base64.b64encode(hmac_code).decode('utf-8') + + separator = '&' if '?' in webhook_url else '?' + url = f'{webhook_url}{separator}timestamp={timestamp}&sign={sign}' if sign else webhook_url + + payload = { + 'msgtype': 'text', + 'text': { + 'content': f'【{title}】\n\n{content}' + } + } + + try: + response = requests.post(url, json=payload, timeout=10) + result = response.json() + if result.get('errcode') == 0: + return True, '' + else: + return False, result.get('errmsg', '发送失败') + except Exception as e: + return False, str(e) + + def _send_wecom_message(self, webhook_url, secret, title, content): + payload = { + 'msgtype': 'text', + 'text': { + 'content': f'【{title}】\n\n{content}' + } + } + + try: + response = requests.post(webhook_url, json=payload, timeout=10) + result = response.json() + if result.get('errcode') == 0: + return True, '' + else: + return False, result.get('errmsg', '发送失败') + except Exception as e: + return False, str(e) diff --git a/app/api/controller/rbacController.py b/app/api/controller/rbacController.py new file mode 100644 index 0000000..8c51c9f --- /dev/null +++ b/app/api/controller/rbacController.py @@ -0,0 +1,257 @@ +# encoding: UTF-8 +from flask import g + +from .baseCrudController import BaseCrudController +from ..model.rbacModel import Role, Permission, Menu, RolePermission +from ..service.rbacService import RbacService + + +class RbacController(BaseCrudController): + def role_list(self): + filters = [] + status = self._get(self.req_data, 'status') + if status not in (None, ''): + filters.append(Menu.status == int(status)) + return RbacService.build_menu_tree( + self.session, + filters, + role_ids=getattr(g, 'current_role_ids', []) + ) + + def role_page_list(self): + filters = [] + keyword = self._get(self.req_data, 'keyword') + status = self._get(self.req_data, 'status') + if keyword: + filters.append(Role.name.like('%{}%'.format(keyword))) + if status not in (None, ''): + filters.append(Role.status == int(status)) + items, total = RbacService.list_by_filters(self.session, Role, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + Role.created_time) + return {'list': self.serialize_list(items, ['is_delete']), 'total': total} + + def role_detail(self): + role_id = self._get(self.req_data, 'roleId', 'id') + if not role_id: + return {}, 'roleId 为必传参数' + item = RbacService.get_by_id(self.session, Role, role_id) + if not item: + return {}, '未查询到对应角色!' + return self.serialize(item, ['is_delete']), '' + + def role_create(self): + code = self._get(self.req_data, 'code') + name = self._get(self.req_data, 'name') + if not code or not name: + return 0, 'code、name 为必传参数' + return RbacService.create(self.session, Role, { + 'code': code, + 'name': name, + 'description': self._get(self.req_data, 'description'), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'is_system': int(self._get(self.req_data, 'isSystem', 'is_system', default=0)), + 'created_by': self._get(self.req_data, 'createdBy'), + 'is_delete': 0 + }) + + def role_update(self): + role_id = self._get(self.req_data, 'roleId', 'id') + if not role_id: + return 0, 'roleId 为必传参数' + update_info = {} + for req_key, column_key in [('code', 'code'), ('name', 'name'), ('description', 'description'), + ('status', 'status'), ('isSystem', 'is_system'), ('is_system', 'is_system')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return RbacService.update_by_id(self.session, Role, role_id, update_info) + + def role_delete(self): + role_id = self._get(self.req_data, 'roleId', 'id') + if not role_id: + return 0, 'roleId 为必传参数' + return RbacService.delete_by_id(self.session, Role, role_id) + + def permission_list(self): + filters = [] + keyword = self._get(self.req_data, 'keyword') + module = self._get(self.req_data, 'module') + status = self._get(self.req_data, 'status') + if keyword: + filters.append(Permission.name.like('%{}%'.format(keyword))) + if module: + filters.append(Permission.module == module) + if status not in (None, ''): + filters.append(Permission.status == int(status)) + items, total = RbacService.list_by_filters(self.session, Permission, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + Permission.created_time) + role_permission_items = self.session.query(RolePermission).filter(RolePermission.is_delete == 0).all() + permission_role_map = {} + for rp in role_permission_items: + if rp.permission_id not in permission_role_map: + permission_role_map[rp.permission_id] = [] + permission_role_map[rp.permission_id].append(rp.role_id) + role_items = self.session.query(Role).filter(Role.is_delete == 0).all() + role_map = {r.id: {'id': r.id, 'name': r.name} for r in role_items} + result_list = [] + for item in items: + item_dict = self.serialize(item, ['is_delete']) + role_ids = permission_role_map.get(item.id, []) + item_dict['roles'] = [role_map.get(rid) for rid in role_ids if role_map.get(rid)] + result_list.append(item_dict) + return {'list': result_list, 'total': total} + + def permission_detail(self): + permission_id = self._get(self.req_data, 'permissionId', 'id') + if not permission_id: + return {}, 'permissionId 为必传参数' + item = RbacService.get_by_id(self.session, Permission, permission_id) + if not item: + return {}, '未查询到对应权限!' + return self.serialize(item, ['is_delete']), '' + + def permission_create(self): + code = self._get(self.req_data, 'code') + name = self._get(self.req_data, 'name') + if not code or not name: + return 0, 'code、name 为必传参数' + return RbacService.create(self.session, Permission, { + 'code': code, + 'name': name, + 'module': self._get(self.req_data, 'module'), + 'action': self._get(self.req_data, 'action'), + 'description': self._get(self.req_data, 'description'), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'is_delete': 0 + }) + + def permission_update(self): + permission_id = self._get(self.req_data, 'permissionId', 'id') + if not permission_id: + return 0, 'permissionId 为必传参数' + update_info = {} + for req_key, column_key in [('code', 'code'), ('name', 'name'), ('module', 'module'), ('action', 'action'), + ('description', 'description'), ('status', 'status')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return RbacService.update_by_id(self.session, Permission, permission_id, update_info) + + def permission_delete(self): + permission_id = self._get(self.req_data, 'permissionId', 'id') + if not permission_id: + return 0, 'permissionId 为必传参数' + return RbacService.delete_by_id(self.session, Permission, permission_id) + + def menu_tree(self): + return RbacService.build_menu_tree(self.session, []) + + def current_menu_list(self): + filters = [] + status = self._get(self.req_data, 'status') + if status not in (None, ''): + filters.append(Menu.status == int(status)) + return RbacService.build_menu_tree( + self.session, + filters, + role_ids=getattr(g, 'current_role_ids', []) + ) + + def role_menu_tree(self): + role_id = self._get(self.req_data, 'roleId') + if not role_id: + return {'tree': [], 'checkedKeys': []}, 'roleId 为必传参数' + return { + 'tree': RbacService.build_menu_tree(self.session, []), + 'checkedKeys': RbacService.get_role_menu_ids(self.session, role_id) + }, '' + + def menu_detail(self): + menu_id = self._get(self.req_data, 'menuId', 'id') + if not menu_id: + return {}, 'menuId 为必传参数' + item = RbacService.get_by_id(self.session, Menu, menu_id) + if not item: + return {}, '未查询到对应菜单!' + return self.serialize(item, ['is_delete']), '' + + def menu_create(self): + name = self._get(self.req_data, 'name') + if not name: + return 0, 'name 为必传参数' + return RbacService.create(self.session, Menu, { + 'parent_id': int(self._get(self.req_data, 'parentId', 'parent_id', default=0)), + 'name': name, + 'code': self._get(self.req_data, 'code'), + 'type': int(self._get(self.req_data, 'type', default=1)), + 'path': self._get(self.req_data, 'path'), + 'component': self._get(self.req_data, 'component'), + 'icon': self._get(self.req_data, 'icon'), + 'permission_code': self._get(self.req_data, 'permissionCode', 'permission_code'), + 'sort': int(self._get(self.req_data, 'sort', default=0)), + 'visible': int(self._get(self.req_data, 'visible', default=1)), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'is_delete': 0 + }) + + def menu_update(self): + menu_id = self._get(self.req_data, 'menuId', 'id') + if not menu_id: + return 0, 'menuId 为必传参数' + update_info = {} + field_pairs = [ + (('parentId', 'parent_id'), 'parent_id'), + (('name',), 'name'), + (('code',), 'code'), + (('type',), 'type'), + (('path',), 'path'), + (('component',), 'component'), + (('icon',), 'icon'), + (('permissionCode', 'permission_code'), 'permission_code'), + (('sort',), 'sort'), + (('visible',), 'visible'), + (('status',), 'status') + ] + for req_keys, column_key in field_pairs: + value = self._get(self.req_data, *req_keys) + if value is not None: + update_info[column_key] = value + 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 为必传参数' + return RbacService.delete_by_id(self.session, Menu, menu_id) + + def role_permission_list(self): + role_id = self._get(self.req_data, 'roleId') + if not role_id: + return {'permissionIds': []} + return {'permissionIds': RbacService.get_role_permission_ids(self.session, role_id)} + + def role_permission_assign(self): + role_ids = self._get(self.req_data, 'roleIds', default=[]) + permission_id = self._get(self.req_data, 'permissionId') + if not role_ids: + return 0, 'roleIds 为必传参数' + if not permission_id: + return 0, 'permissionId 为必传参数' + return RbacService.assign_permissions(self.session, role_ids, permission_id) + + def role_menu_list(self): + role_id = self._get(self.req_data, 'roleId') + if not role_id: + return {'menuIds': []} + return {'menuIds': RbacService.get_role_menu_ids(self.session, role_id)} + + def role_menu_assign(self): + role_id = self._get(self.req_data, 'roleId') + menu_ids = self._get(self.req_data, 'menuIds', default=[]) + if not role_id: + return 0, 'roleId 为必传参数' + return RbacService.assign_menus(self.session, role_id, menu_ids) diff --git a/app/api/controller/reportController.py b/app/api/controller/reportController.py new file mode 100644 index 0000000..3a7cea4 --- /dev/null +++ b/app/api/controller/reportController.py @@ -0,0 +1,47 @@ +# encoding: UTF-8 +from .baseCrudController import BaseCrudController +from ..model.planModel import TestPlan +from ..model.reportModel import Report +from ..service.reportService import ReportService + + +class ReportController(BaseCrudController): + """测试报告相关接口控制器。""" + + def report_list(self): + """分页查询报告列表,可按产品、项目、计划过滤。""" + filters = [] + product_id = self._get(self.req_data, 'productId', 'product_id') + if product_id: + filters.append(Report.product_id == int(product_id)) + project_id = self._get(self.req_data, 'projectId', 'project_id') + if project_id: + filters.append(Report.project_id == int(project_id)) + plan_id = self._get(self.req_data, 'planId', 'plan_id') + if plan_id: + filters.append(Report.plan_id == int(plan_id)) + items, total = ReportService.list_by_filters(self.session, Report, filters, self._get(self.req_data, 'pageNo', default=1), self._get(self.req_data, 'pageSize', default=20), Report.generated_time) + result_list = [] + for item in items: + item_dict = self.serialize(item) + plan = self.session.query(TestPlan).filter(TestPlan.id == item.plan_id).first() + item_dict['plan_name'] = plan.name if plan else None + result_list.append(item_dict) + return {'list': result_list, 'total': total} + + def report_detail(self): + """查询报告详情,返回 summary 和 HTML content。""" + report_id = self._get(self.req_data, 'reportId', 'report_id', 'id') + if not report_id: + return {}, 'reportId 为必传参数' + item = ReportService.get_by_id(self.session, Report, report_id) + if not item: + return {}, '未查询到对应报告!' + return self.serialize(item), '' + + def report_generate(self): + """同步生成报告:聚合计划执行数据并落库。""" + plan_id = self._get(self.req_data, 'planId', 'plan_id') + if not plan_id: + return 0, 'planId 为必传参数' + return ReportService.generate_report(self.session, plan_id, self._get(self.req_data, 'generatedBy', 'generated_by')) diff --git a/app/api/controller/userController.py b/app/api/controller/userController.py new file mode 100644 index 0000000..48f4565 --- /dev/null +++ b/app/api/controller/userController.py @@ -0,0 +1,128 @@ +# encoding: UTF-8 +from .baseCrudController import BaseCrudController +from ..model.userModel import User +from ..service.userService import UserService +from ..utils.authMiddleware import TOKEN_REFRESH_THRESHOLD_SECONDS, create_token + + +class UserController(BaseCrudController): + def user_list(self): + filters = [] + keyword = self._get(self.req_data, 'keyword') + status = self._get(self.req_data, 'status') + if keyword: + filters.append(User.username.like('%{}%'.format(keyword))) + if status not in (None, ''): + filters.append(User.status == int(status)) + items, total = UserService.list_by_filters(self.session, User, filters, + self._get(self.req_data, 'pageNo', 'page', default=1), + self._get(self.req_data, 'pageSize', 'size', default=20), + User.created_time) + result_list = self.serialize_list(items, ['is_delete', 'password_hash']) + role_map = UserService.get_user_roles_map(self.session, [item.id for item in items]) + for item in result_list: + role_info = role_map.get(item.get('id'), {'role_ids': [], 'role_names': []}) + item['role_ids'] = role_info['role_ids'] + item['role_names'] = role_info['role_names'] + return {'list': result_list, 'total': total} + + def user_detail(self): + user_id = self._get(self.req_data, 'userId', 'id') + if not user_id: + return {}, 'userId 为必传参数' + item = UserService.get_by_id(self.session, User, user_id) + if not item: + return {}, '未查询到对应用户!' + ret = self.serialize(item, ['is_delete', 'password_hash']) + ret['role_ids'] = UserService.get_user_role_ids(self.session, user_id) + return ret, '' + + def user_create(self): + username = self._get(self.req_data, 'username') + password = self._get(self.req_data, 'password') + if not username or not password: + return 0, 'username、password 为必传参数' + return UserService.create(self.session, User, { + 'username': username, + 'real_name': self._get(self.req_data, 'realName', 'real_name'), + 'password_hash': password, + 'mobile': self._get(self.req_data, 'mobile'), + 'email': self._get(self.req_data, 'email'), + 'avatar': self._get(self.req_data, 'avatar'), + 'status': int(self._get(self.req_data, 'status', default=1)), + 'created_by': self._get(self.req_data, 'createdBy'), + 'is_delete': 0 + }) + + def user_update(self): + user_id = self._get(self.req_data, 'userId', 'id') + if not user_id: + return 0, 'userId 为必传参数' + update_info = {} + for req_key, column_key in [('username', 'username'), ('realName', 'real_name'), ('real_name', 'real_name'), + ('password', 'password_hash'), ('mobile', 'mobile'), ('email', 'email'), + ('avatar', 'avatar'), ('status', 'status')]: + value = self._get(self.req_data, req_key) + if value is not None: + update_info[column_key] = value + return UserService.update_by_id(self.session, User, user_id, update_info) + + def user_delete(self): + user_id = self._get(self.req_data, 'userId', 'id') + if not user_id: + return 0, 'userId 为必传参数' + return UserService.delete_by_id(self.session, User, user_id) + + def user_role_list(self): + user_id = self._get(self.req_data, 'userId') + if not user_id: + return {'roleIds': []} + return {'roleIds': UserService.get_user_role_ids(self.session, user_id)} + + def user_role_assign(self): + user_id = self._get(self.req_data, 'userId') + role_ids = self._get(self.req_data, 'roleIds', default=[]) + if not user_id: + return 0, 'userId 为必传参数' + return UserService.assign_roles(self.session, user_id, role_ids) + + def register(self): + username = self._get(self.req_data, 'username') + password = self._get(self.req_data, 'password') + if not username or not password: + return 0, 'username、password 为必传参数' + exist_user = UserService.get_by_username(self.session, username) + if exist_user: + return 0, '用户名已存在!' + return UserService.create(self.session, User, { + 'username': username, + 'real_name': self._get(self.req_data, 'realName', 'real_name'), + 'password_hash': password, + 'mobile': self._get(self.req_data, 'mobile'), + 'email': self._get(self.req_data, 'email'), + 'avatar': self._get(self.req_data, 'avatar'), + 'status': 1, + 'created_by': self._get(self.req_data, 'createdBy'), + 'is_delete': 0 + }) + + def login(self): + username = self._get(self.req_data, 'username') + password = self._get(self.req_data, 'password') + if not username or not password: + return {}, 'username、password 为必传参数' + user = UserService.get_by_username(self.session, username) + if not user or user.password_hash != password: + return {}, '用户名或密码错误!' + if int(user.status) != 1: + return {}, '用户已禁用!' + UserService.update_last_login_time(self.session, user.id) + token, expire_seconds = create_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_threshold_seconds'] = TOKEN_REFRESH_THRESHOLD_SECONDS + ret['refresh_mechanism'] = '请求任意已登录接口时,若token剩余有效期小于阈值则自动续期到完整有效期' + return ret, '' diff --git a/app/api/dao/__pycache__/__init__.cpython-38.pyc b/app/api/dao/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..a9603d4 Binary files /dev/null and b/app/api/dao/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/bugDao.cpython-38.pyc b/app/api/dao/__pycache__/bugDao.cpython-38.pyc new file mode 100644 index 0000000..ba8a5f0 Binary files /dev/null and b/app/api/dao/__pycache__/bugDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/caseDao.cpython-38.pyc b/app/api/dao/__pycache__/caseDao.cpython-38.pyc new file mode 100644 index 0000000..5ad399c Binary files /dev/null and b/app/api/dao/__pycache__/caseDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/dataBuilderDao.cpython-38.pyc b/app/api/dao/__pycache__/dataBuilderDao.cpython-38.pyc new file mode 100644 index 0000000..dad2310 Binary files /dev/null and b/app/api/dao/__pycache__/dataBuilderDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/planDao.cpython-38.pyc b/app/api/dao/__pycache__/planDao.cpython-38.pyc new file mode 100644 index 0000000..c2661a2 Binary files /dev/null and b/app/api/dao/__pycache__/planDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/productDao.cpython-38.pyc b/app/api/dao/__pycache__/productDao.cpython-38.pyc new file mode 100644 index 0000000..24855d4 Binary files /dev/null and b/app/api/dao/__pycache__/productDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/projectDao.cpython-38.pyc b/app/api/dao/__pycache__/projectDao.cpython-38.pyc new file mode 100644 index 0000000..4f67a2f Binary files /dev/null and b/app/api/dao/__pycache__/projectDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/projectHookDao.cpython-38.pyc b/app/api/dao/__pycache__/projectHookDao.cpython-38.pyc new file mode 100644 index 0000000..a0e1358 Binary files /dev/null and b/app/api/dao/__pycache__/projectHookDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/rbacDao.cpython-38.pyc b/app/api/dao/__pycache__/rbacDao.cpython-38.pyc new file mode 100644 index 0000000..586e1e3 Binary files /dev/null and b/app/api/dao/__pycache__/rbacDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/reportDao.cpython-38.pyc b/app/api/dao/__pycache__/reportDao.cpython-38.pyc new file mode 100644 index 0000000..14fc906 Binary files /dev/null and b/app/api/dao/__pycache__/reportDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/updateSqlProjectDao.cpython-38.pyc b/app/api/dao/__pycache__/updateSqlProjectDao.cpython-38.pyc new file mode 100644 index 0000000..aa6728f Binary files /dev/null and b/app/api/dao/__pycache__/updateSqlProjectDao.cpython-38.pyc differ diff --git a/app/api/dao/__pycache__/userDao.cpython-38.pyc b/app/api/dao/__pycache__/userDao.cpython-38.pyc new file mode 100644 index 0000000..5ba8aa3 Binary files /dev/null and b/app/api/dao/__pycache__/userDao.cpython-38.pyc differ diff --git a/app/api/dao/bugDao.py b/app/api/dao/bugDao.py new file mode 100644 index 0000000..fbf76c9 --- /dev/null +++ b/app/api/dao/bugDao.py @@ -0,0 +1,224 @@ +# encoding: UTF-8 +from sqlalchemy import func, cast, Date + +from ..model.bugModel import Bug, BugComment, BugHistory +from ..model.userModel import User +from ..model.caseModel import Module +from logger import logger + + +class BugDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None, asc=False): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.asc() if asc else order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return BugDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def generate_bug_key(session): + max_key = session.query(func.max(Bug.bug_key)).filter(Bug.bug_key.like('BUG-%')).scalar() + if max_key: + num = int(max_key.split('-')[1]) + 1 + else: + num = 1 + return f'BUG-{num:03d}' + + @staticmethod + def get_comments(session, bug_id): + return session.query(BugComment).filter( + BugComment.bug_id == int(bug_id), + BugComment.is_delete == 0 + ).order_by(BugComment.created_time.desc()).all() + + @staticmethod + def get_history(session, bug_id): + return session.query(BugHistory).filter( + BugHistory.bug_id == int(bug_id) + ).order_by(BugHistory.created_time.desc()).all() + + @staticmethod + def add_history(session, bug_id, field_name, old_value, new_value, operator_id): + session.add(BugHistory( + bug_id=bug_id, + field_name=field_name, + old_value=str(old_value) if old_value else None, + new_value=str(new_value) if new_value else None, + operator_id=operator_id + )) + err = session.done(close=False) + if err: + logger.warning(f'BugHistory新增失败!{err}') + return False + return True + + @staticmethod + def get_stats(session, product_id=None, project_id=None): + query = session.query(Bug).filter(Bug.is_delete == 0) + if product_id: + query = query.filter(Bug.product_id == int(product_id)) + if project_id: + query = query.filter(Bug.project_id == int(project_id)) + + total = query.count() + new_count = query.filter(Bug.status == 0).count() + pending_count = query.filter(Bug.status == 1).count() + in_progress_count = query.filter(Bug.status == 2).count() + resolved_count = query.filter(Bug.status == 3).count() + closed_count = query.filter(Bug.status == 4).count() + rejected_count = query.filter(Bug.status == 5).count() + + by_status = {} + for status in range(6): + by_status[str(status)] = query.filter(Bug.status == status).count() + + by_solution = {} + solution_results = session.query( + Bug.solution, func.count(Bug.id) + ).filter(Bug.is_delete == 0) + if product_id: + solution_results = solution_results.filter(Bug.product_id == int(product_id)) + if project_id: + solution_results = solution_results.filter(Bug.project_id == int(project_id)) + solution_results = solution_results.filter(Bug.solution.isnot(None)).group_by(Bug.solution).all() + for solution, count in solution_results: + by_solution[solution] = count + + by_reporter = {} + reporter_results = session.query( + User.real_name, func.count(Bug.id) + ).join(User, Bug.reporter_id == User.id).filter(Bug.is_delete == 0) + if product_id: + reporter_results = reporter_results.filter(Bug.product_id == int(product_id)) + if project_id: + reporter_results = reporter_results.filter(Bug.project_id == int(project_id)) + reporter_results = reporter_results.group_by(User.real_name).all() + for name, count in reporter_results: + by_reporter[name] = count + + by_assignee = {} + assignee_results = session.query( + User.real_name, func.count(Bug.id) + ).outerjoin(User, Bug.assignee_id == User.id).filter(Bug.is_delete == 0) + if product_id: + assignee_results = assignee_results.filter(Bug.product_id == int(product_id)) + if project_id: + assignee_results = assignee_results.filter(Bug.project_id == int(project_id)) + assignee_results = assignee_results.group_by(User.real_name).all() + for name, count in assignee_results: + by_assignee[name or '未指派'] = count + + by_resolver = {} + resolver_results = session.query( + User.real_name, func.count(Bug.id) + ).outerjoin(User, Bug.resolved_by == User.id).filter(Bug.is_delete == 0) + if product_id: + resolver_results = resolver_results.filter(Bug.product_id == int(product_id)) + if project_id: + resolver_results = resolver_results.filter(Bug.project_id == int(project_id)) + resolver_results = resolver_results.group_by(User.real_name).all() + for name, count in resolver_results: + by_resolver[name or '未解决'] = count + + by_module = {} + module_results = session.query( + Module.name, func.count(Bug.id) + ).outerjoin(Module, Bug.module_id == Module.id).filter(Bug.is_delete == 0) + if product_id: + module_results = module_results.filter(Bug.product_id == int(product_id)) + if project_id: + module_results = module_results.filter(Bug.project_id == int(project_id)) + module_results = module_results.group_by(Module.name).all() + for name, count in module_results: + by_module[name or '未分类'] = count + + by_version = {} + version_results = session.query( + Bug.resolve_version, func.count(Bug.id) + ).filter(Bug.is_delete == 0) + if product_id: + version_results = version_results.filter(Bug.product_id == int(product_id)) + if project_id: + version_results = version_results.filter(Bug.project_id == int(project_id)) + version_results = version_results.filter(Bug.resolve_version.isnot(None)).group_by(Bug.resolve_version).all() + for version, count in version_results: + by_version[version] = count + + by_activation = {} + + daily_new = {} + daily_new_results = session.query( + cast(Bug.created_time, Date).label('stat_date'), + func.count(Bug.id) + ).filter(Bug.is_delete == 0) + if product_id: + daily_new_results = daily_new_results.filter(Bug.product_id == int(product_id)) + if project_id: + daily_new_results = daily_new_results.filter(Bug.project_id == int(project_id)) + daily_new_results = daily_new_results.group_by('stat_date').order_by('stat_date').all() + for date, count in daily_new_results: + daily_new[str(date)] = count + + daily_resolved = {} + + daily_closed = {} + + return { + 'total': total, + 'new': new_count, + 'pending': pending_count, + 'in_progress': in_progress_count, + 'resolved': resolved_count, + 'closed': closed_count, + 'rejected': rejected_count, + 'by_status': by_status, + 'by_solution': by_solution, + 'by_reporter': by_reporter, + 'by_assignee': by_assignee, + 'by_resolver': by_resolver, + 'by_module': by_module, + 'by_version': by_version, + 'by_activation': by_activation, + 'daily_new': daily_new, + 'daily_resolved': daily_resolved, + 'daily_closed': daily_closed + } diff --git a/app/api/dao/caseDao.py b/app/api/dao/caseDao.py new file mode 100644 index 0000000..e904e1c --- /dev/null +++ b/app/api/dao/caseDao.py @@ -0,0 +1,90 @@ +# encoding: UTF-8 +from sqlalchemy import func + +from ..model.caseModel import CaseReview, CaseSnapshot, Module, TestCase +from logger import logger + + +class CaseDao(object): + """用例域通用 DAO,复用模块、用例、快照、评审的基础操作。""" + + @staticmethod + def create(session, model_cls, add_info): + """创建记录并提交事务。""" + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + 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) + + @staticmethod + def next_snapshot_version(session, case_id): + """生成用例快照版本号。""" + max_version = session.query(func.max(CaseSnapshot.version)).filter(CaseSnapshot.case_id == int(case_id)).scalar() or 0 + return int(max_version) + 1 + + @staticmethod + def module_model(): + return Module + + @staticmethod + def case_model(): + return TestCase + + @staticmethod + def snapshot_model(): + return CaseSnapshot + + @staticmethod + def review_model(): + return CaseReview + + @staticmethod + def get_module_name_map(session, module_ids): + if not module_ids: + return {} + module_items = session.query(Module).filter(Module.id.in_(module_ids), Module.is_delete == 0).all() + return {module.id: module.name for module in module_items} diff --git a/app/api/dao/dataBuilderDao.py b/app/api/dao/dataBuilderDao.py new file mode 100644 index 0000000..9fcdb38 --- /dev/null +++ b/app/api/dao/dataBuilderDao.py @@ -0,0 +1,59 @@ +# encoding: UTF-8 +from ..model.dataBuilderModel import DataBuilder, DataTask +from logger import logger + + +class DataBuilderDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return DataBuilderDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def builder_model(): + return DataBuilder + + @staticmethod + def task_model(): + return DataTask diff --git a/app/api/dao/planDao.py b/app/api/dao/planDao.py new file mode 100644 index 0000000..b03f440 --- /dev/null +++ b/app/api/dao/planDao.py @@ -0,0 +1,88 @@ +# encoding: UTF-8 +from sqlalchemy import func + +from ..model.planModel import PlanCase, TestPlan, TestRound +from logger import logger + + +class PlanDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def batch_create(session, model_cls, batch_info_list): + if not batch_info_list: + return 0, '' + objs = [model_cls(**info) for info in batch_info_list] + session.add_all(objs) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}批量新增失败!{err}') + return 0, f'批量新增失败!{err}' + return len(objs), '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None, asc=False): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.asc() if asc else order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return PlanDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def plan_stats(session, plan_id): + """聚合计划执行进度,用于计划详情、进度看板和报告生成。""" + total = session.query(func.count(PlanCase.id)).filter(PlanCase.plan_id == int(plan_id)).scalar() or 0 + passed = session.query(func.count(PlanCase.id)).filter(PlanCase.plan_id == int(plan_id), PlanCase.status == 1).scalar() or 0 + failed = session.query(func.count(PlanCase.id)).filter(PlanCase.plan_id == int(plan_id), PlanCase.status == 2).scalar() or 0 + blocked = session.query(func.count(PlanCase.id)).filter(PlanCase.plan_id == int(plan_id), PlanCase.status == 3).scalar() or 0 + completed = passed + failed + blocked + pass_rate = round(passed / total * 100, 2) if total else 0 + return {'total_cases': total, 'completed': completed, 'passed': passed, 'failed': failed, 'blocked': blocked, 'pass_rate': pass_rate} + + @staticmethod + def plan_model(): + return TestPlan + + @staticmethod + def plan_case_model(): + return PlanCase + + @staticmethod + def round_model(): + return TestRound diff --git a/app/api/dao/productDao.py b/app/api/dao/productDao.py new file mode 100644 index 0000000..092e088 --- /dev/null +++ b/app/api/dao/productDao.py @@ -0,0 +1,55 @@ +# encoding: UTF-8 +from ..model.productModel import Product +from logger import logger + + +class ProductDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProductDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def product_model(): + return Product diff --git a/app/api/dao/projectDao.py b/app/api/dao/projectDao.py new file mode 100644 index 0000000..393f7ed --- /dev/null +++ b/app/api/dao/projectDao.py @@ -0,0 +1,79 @@ +# encoding: UTF-8 +from ..model.productModel import Product +from ..model.projectModel import Environment, Project, ProjectMember +from logger import logger + + +class ProjectDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + """按过滤条件分页查询;存在 is_delete 字段时统一过滤未删除数据。""" + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProjectDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def get_product_map(session, product_ids): + if not product_ids: + return {} + product_items = session.query(Product).filter(Product.id.in_(product_ids), Product.is_delete == 0).all() + return {product.id: product.name for product in product_items} + + @staticmethod + def get_project_name_map(session, project_ids): + if not project_ids: + return {} + project_items = session.query(Project).filter(Project.id.in_(project_ids), Project.is_delete == 0).all() + return {project.id: {'name': project.name} for project in project_items} + + @staticmethod + def project_model(): + return Project + + @staticmethod + def member_model(): + return ProjectMember + + @staticmethod + def environment_model(): + return Environment diff --git a/app/api/dao/projectHookDao.py b/app/api/dao/projectHookDao.py new file mode 100644 index 0000000..ce863f3 --- /dev/null +++ b/app/api/dao/projectHookDao.py @@ -0,0 +1,62 @@ +# encoding: UTF-8 +from ..model.projectHookModel import ProjectHook +from logger import logger + + +class ProjectHookDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProjectHookDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def hook_model(): + return ProjectHook + + @staticmethod + def list_all_by_filters(session, model_cls, filter_list): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + return query.all() \ No newline at end of file diff --git a/app/api/dao/rbacDao.py b/app/api/dao/rbacDao.py new file mode 100644 index 0000000..43a7a20 --- /dev/null +++ b/app/api/dao/rbacDao.py @@ -0,0 +1,185 @@ +# encoding: UTF-8 +from ..model.rbacModel import Role, Permission, RolePermission, Menu, RoleMenu +from logger import logger + + +class RbacDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return RbacDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def get_role_permission_ids(session, role_id): + items = session.query(RolePermission).filter(RolePermission.role_id == int(role_id), RolePermission.is_delete == 0).all() + return [item.permission_id for item in items] + + @staticmethod + def replace_role_permissions(session, role_id, permission_ids): + role_id = int(role_id) + normalized_permission_ids = [] + for permission_id in permission_ids: + permission_id = int(permission_id) + if permission_id not in normalized_permission_ids: + normalized_permission_ids.append(permission_id) + session.query(RolePermission).filter(RolePermission.role_id == role_id, RolePermission.is_delete == 0).update({'is_delete': 1}) + session.flush() + if normalized_permission_ids: + existing_items = session.query(RolePermission).filter( + RolePermission.role_id == role_id, + RolePermission.permission_id.in_(normalized_permission_ids) + ).all() + existing_map = {item.permission_id: item for item in existing_items} + for permission_id in normalized_permission_ids: + existing_item = existing_map.get(permission_id) + if existing_item: + existing_item.is_delete = 0 + else: + session.add(RolePermission(role_id=role_id, permission_id=permission_id, is_delete=0)) + err = session.done(close=False) + if err: + return 0, f'分配权限失败!{err}' + return role_id, '' + + @staticmethod + def assign_permissions_to_roles(session, role_ids, permission_id): + permission_id = int(permission_id) + normalized_role_ids = [] + for role_id in role_ids: + role_id = int(role_id) + if role_id not in normalized_role_ids: + normalized_role_ids.append(role_id) + existing_items = session.query(RolePermission).filter( + RolePermission.role_id.in_(normalized_role_ids), + RolePermission.permission_id == permission_id + ).all() + existing_role_ids = {item.role_id for item in existing_items} + for role_id in normalized_role_ids: + if role_id not in existing_role_ids: + session.add(RolePermission(role_id=role_id, permission_id=permission_id, is_delete=0)) + else: + existing_item = next(item for item in existing_items if item.role_id == role_id) + if existing_item.is_delete == 1: + existing_item.is_delete = 0 + err = session.done(close=False) + if err: + return 0, f'分配权限失败!{err}' + return len(normalized_role_ids), '' + + @staticmethod + def get_role_menu_ids(session, role_id): + items = session.query(RoleMenu).filter(RoleMenu.role_id == int(role_id), RoleMenu.is_delete == 0).all() + return [item.menu_id for item in items] + + @staticmethod + def replace_role_menus(session, role_id, menu_ids): + role_id = int(role_id) + normalized_menu_ids = [] + for menu_id in menu_ids: + menu_id = int(menu_id) + if menu_id not in normalized_menu_ids: + normalized_menu_ids.append(menu_id) + session.query(RoleMenu).filter(RoleMenu.role_id == role_id, RoleMenu.is_delete == 0).update({'is_delete': 1}) + if normalized_menu_ids: + existing_items = session.query(RoleMenu).filter( + RoleMenu.role_id == role_id, + RoleMenu.menu_id.in_(normalized_menu_ids) + ).all() + existing_map = {item.menu_id: item for item in existing_items} + for menu_id in normalized_menu_ids: + existing_item = existing_map.get(menu_id) + if existing_item: + existing_item.is_delete = 0 + else: + session.add(RoleMenu(role_id=role_id, menu_id=menu_id, is_delete=0)) + err = session.done(close=False) + if err: + return 0, f'分配菜单失败!{err}' + return role_id, '' + + @staticmethod + def get_role_names_map(session, role_ids): + if not role_ids: + return {} + items = session.query(Role).filter(Role.id.in_(role_ids), Role.is_delete == 0).all() + return {item.id: item.name for item in items} + + @staticmethod + def get_role_permission_codes(session, role_ids): + if not role_ids: + return [] + permission_items = session.query(Permission.code).join( + RolePermission, RolePermission.permission_id == Permission.id + ).filter( + RolePermission.role_id.in_(role_ids), RolePermission.is_delete == 0, + Permission.is_delete == 0, Permission.status == 1 + ).all() + menu_items = session.query(Menu.permission_code).join( + RoleMenu, RoleMenu.menu_id == Menu.id + ).filter( + RoleMenu.role_id.in_(role_ids), RoleMenu.is_delete == 0, + Menu.is_delete == 0, Menu.status == 1 + ).all() + return sorted(list({item[0] for item in permission_items if item[0]} | {item[0] for item in menu_items if item[0]})) + + @staticmethod + def get_menu_tree_items(session, filter_list): + return session.query(Menu).filter(*filter_list, Menu.is_delete == 0).order_by(Menu.sort.asc(), Menu.id.asc()).all() + + @staticmethod + def role_model(): + return Role + + @staticmethod + def permission_model(): + return Permission + + @staticmethod + def menu_model(): + return Menu + + @staticmethod + 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} diff --git a/app/api/dao/reportDao.py b/app/api/dao/reportDao.py new file mode 100644 index 0000000..a523b95 --- /dev/null +++ b/app/api/dao/reportDao.py @@ -0,0 +1,36 @@ +# encoding: UTF-8 +from ..model.reportModel import DefectSync, Report +from logger import logger + + +class ReportDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def get_by_id(session, model_cls, obj_id): + return session.query(model_cls).filter(model_cls.id == int(obj_id)).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None, asc=False): + query = session.query(model_cls).filter(*filter_list) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.asc() if asc else order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def report_model(): + return Report + + @staticmethod + def defect_model(): + return DefectSync diff --git a/app/api/dao/updateSqlProjectDao.py b/app/api/dao/updateSqlProjectDao.py index 2b686d7..1917a01 100644 --- a/app/api/dao/updateSqlProjectDao.py +++ b/app/api/dao/updateSqlProjectDao.py @@ -43,15 +43,12 @@ class UpdateSqlProjectDao(object): @staticmethod def get_sql_by_filters(session, filter_list, page=1, limit=20): - rets = session.query(UpdateSqlProject)\ - .filter(*filter_list) \ - .filter(UpdateSqlProject.is_delete == 0) \ - .order_by(UpdateSqlProject.created_time.desc()) \ + query = session.query(UpdateSqlProject).filter(*filter_list).filter(UpdateSqlProject.is_delete == 0) + total = query.count() + rets = query.order_by(UpdateSqlProject.created_time.desc()) \ .offset((int(page) - 1) * int(limit)) \ .limit(limit) \ .all() - total = session.query(UpdateSqlProject).filter(*filter_list).filter( - UpdateSqlProject.is_delete == 0).count() return rets, total @staticmethod diff --git a/app/api/dao/userDao.py b/app/api/dao/userDao.py new file mode 100644 index 0000000..e1aecca --- /dev/null +++ b/app/api/dao/userDao.py @@ -0,0 +1,109 @@ +# encoding: UTF-8 +from datetime import datetime + +from ..model.userModel import User, UserRole +from logger import logger + + +class UserDao(object): + @staticmethod + def create(session, model_cls, add_info): + obj = model_cls(**add_info) + session.add(obj) + err = session.done(close=False) + if err: + logger.warning(f'{model_cls.__name__}新增失败!{err}') + return 0, f'新增失败!{err}' + return obj.id, '' + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + update_res = session.query(model_cls).filter(*filters).update(update_info) + err = session.done(close=False) + if err: + logger.error(f'{model_cls.__name__}更新失败!id: {obj_id}, err: {err}') + return 0, f'更新失败!{err}' + if not update_res: + return 0, '未查询到对应记录!' + return int(obj_id), '' + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + filters = [model_cls.id == int(obj_id)] + if soft_delete and hasattr(model_cls, 'is_delete'): + filters.append(model_cls.is_delete == 0) + return session.query(model_cls).filter(*filters).first() + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page=1, limit=20, order_column=None): + query = session.query(model_cls).filter(*filter_list) + if hasattr(model_cls, 'is_delete'): + query = query.filter(model_cls.is_delete == 0) + total = query.count() + if order_column is not None: + query = query.order_by(order_column.desc()) + rets = query.offset((int(page) - 1) * int(limit)).limit(int(limit)).all() + return rets, total + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return UserDao.update_by_id(session, model_cls, obj_id, {'is_delete': 1}) + + @staticmethod + def get_user_role_ids(session, user_id): + items = session.query(UserRole).filter(UserRole.user_id == int(user_id), UserRole.is_delete == 0).all() + return [item.role_id for item in items] + + @staticmethod + def replace_user_roles(session, user_id, role_ids): + user_id = int(user_id) + role_ids = [int(role_id) for role_id in role_ids] + session.query(UserRole).filter(UserRole.user_id == user_id, UserRole.is_delete == 0).update({'is_delete': 1}) + existing_items = session.query(UserRole).filter(UserRole.user_id == user_id).all() + existing_map = {item.role_id: item for item in existing_items} + for role_id in role_ids: + existing_item = existing_map.get(role_id) + if existing_item: + existing_item.is_delete = 0 + else: + session.add(UserRole(user_id=user_id, role_id=role_id, is_delete=0)) + err = session.done(close=False) + if err: + return 0, f'分配角色失败!{err}' + return user_id, '' + + @staticmethod + def get_user_roles(session, user_ids): + if not user_ids: + return {} + items = session.query(UserRole).filter(UserRole.user_id.in_(user_ids), UserRole.is_delete == 0).all() + ret = {} + for item in items: + ret.setdefault(item.user_id, []).append(item.role_id) + return ret + + @staticmethod + def get_by_username(session, username): + return session.query(User).filter(User.username == username, User.is_delete == 0).first() + + @staticmethod + def update_last_login_time(session, user_id): + session.query(User).filter(User.id == int(user_id), User.is_delete == 0).update({'last_login_time': datetime.now()}) + err = session.done(close=False) + if err: + return 0, f'更新登录时间失败!{err}' + return int(user_id), '' + + @staticmethod + def user_model(): + return User + + @staticmethod + def get_user_info_map(session, user_ids): + if not user_ids: + return {} + items = session.query(User).filter(User.id.in_(user_ids), User.is_delete == 0).all() + return {item.id: {'username': item.username, 'real_name': item.real_name} for item in items} diff --git a/app/api/model/__pycache__/__init__.cpython-38.pyc b/app/api/model/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..cf50edc Binary files /dev/null and b/app/api/model/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/bugModel.cpython-38.pyc b/app/api/model/__pycache__/bugModel.cpython-38.pyc new file mode 100644 index 0000000..901f127 Binary files /dev/null and b/app/api/model/__pycache__/bugModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/caseModel.cpython-38.pyc b/app/api/model/__pycache__/caseModel.cpython-38.pyc new file mode 100644 index 0000000..58bf4b7 Binary files /dev/null and b/app/api/model/__pycache__/caseModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/dataBuilderModel.cpython-38.pyc b/app/api/model/__pycache__/dataBuilderModel.cpython-38.pyc new file mode 100644 index 0000000..b0d27cc Binary files /dev/null and b/app/api/model/__pycache__/dataBuilderModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/planModel.cpython-38.pyc b/app/api/model/__pycache__/planModel.cpython-38.pyc new file mode 100644 index 0000000..1c0a138 Binary files /dev/null and b/app/api/model/__pycache__/planModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/productModel.cpython-38.pyc b/app/api/model/__pycache__/productModel.cpython-38.pyc new file mode 100644 index 0000000..324fcd2 Binary files /dev/null and b/app/api/model/__pycache__/productModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/projectHookModel.cpython-38.pyc b/app/api/model/__pycache__/projectHookModel.cpython-38.pyc new file mode 100644 index 0000000..820a698 Binary files /dev/null and b/app/api/model/__pycache__/projectHookModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/projectModel.cpython-38.pyc b/app/api/model/__pycache__/projectModel.cpython-38.pyc new file mode 100644 index 0000000..3130e11 Binary files /dev/null and b/app/api/model/__pycache__/projectModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/rbacModel.cpython-38.pyc b/app/api/model/__pycache__/rbacModel.cpython-38.pyc new file mode 100644 index 0000000..6d42496 Binary files /dev/null and b/app/api/model/__pycache__/rbacModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/reportModel.cpython-38.pyc b/app/api/model/__pycache__/reportModel.cpython-38.pyc new file mode 100644 index 0000000..6739ead Binary files /dev/null and b/app/api/model/__pycache__/reportModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/updateSqlProjectModel.cpython-38.pyc b/app/api/model/__pycache__/updateSqlProjectModel.cpython-38.pyc new file mode 100644 index 0000000..333b983 Binary files /dev/null and b/app/api/model/__pycache__/updateSqlProjectModel.cpython-38.pyc differ diff --git a/app/api/model/__pycache__/userModel.cpython-38.pyc b/app/api/model/__pycache__/userModel.cpython-38.pyc new file mode 100644 index 0000000..8765760 Binary files /dev/null and b/app/api/model/__pycache__/userModel.cpython-38.pyc differ diff --git a/app/api/model/bugModel.py b/app/api/model/bugModel.py new file mode 100644 index 0000000..1d2f059 --- /dev/null +++ b/app/api/model/bugModel.py @@ -0,0 +1,58 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, Text, TIMESTAMP, 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 Bug(Base): + __tablename__ = 'bug' + id = Column(BigInteger, primary_key=True, autoincrement=True) + bug_key = Column(String(64), nullable=False, unique=True) + title = Column(String(256), nullable=False) + description = Column(Text) + bug_type = Column(SmallInteger, nullable=False, default=1) + severity = Column(SmallInteger, nullable=False, default=2) + priority = Column(SmallInteger, nullable=False, default=2) + status = Column(SmallInteger, nullable=False, default=0) + assignee_id = Column(BigInteger) + reporter_id = Column(BigInteger, nullable=False) + product_id = Column(BigInteger, nullable=False) + project_id = Column(BigInteger, nullable=False) + module_id = Column(BigInteger) + case_id = Column(BigInteger) + plan_id = Column(BigInteger) + environment = Column(String(64)) + steps = Column(Text) + solution = Column(Text) + resolve_version = Column(String(64)) + resolved_by = Column(BigInteger) + reproduce_rate = Column(SmallInteger) + attachments = Column(JSONB, server_default=text("'[]'::jsonb")) + is_delete = Column(Integer, default=0) + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True) + updated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), server_onupdate=text('CURRENT_TIMESTAMP'), nullable=True) + + +class BugComment(Base): + __tablename__ = 'bug_comment' + id = Column(BigInteger, primary_key=True, autoincrement=True) + bug_id = Column(BigInteger, nullable=False) + content = Column(Text, nullable=False) + user_id = Column(BigInteger, nullable=False) + is_delete = Column(Integer, default=0) + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True) + + +class BugHistory(Base): + __tablename__ = 'bug_history' + id = Column(BigInteger, primary_key=True, autoincrement=True) + bug_id = Column(BigInteger, nullable=False) + field_name = Column(String(64), nullable=False) + old_value = Column(String(512)) + new_value = Column(String(512)) + operator_id = Column(BigInteger, nullable=False) + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True) \ No newline at end of file diff --git a/app/api/model/caseModel.py b/app/api/model/caseModel.py new file mode 100644 index 0000000..e798739 --- /dev/null +++ b/app/api/model/caseModel.py @@ -0,0 +1,62 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, TIMESTAMP, Text, text +from sqlalchemy.dialects.postgresql import ARRAY, JSONB +from sqlalchemy.ext.declarative import declarative_base + +from common.sqlSession import to_dict + +Base = declarative_base() +Base.to_dict = to_dict + + +class Module(Base): + __tablename__ = 'module' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + parent_id = Column(BigInteger, default=0, comment='父模块id') + name = Column(String(128), nullable=False, comment='模块名称') + sort_order = Column(Integer, default=0, comment='排序') + path = Column(String(512), comment='模块路径') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + + +class TestCase(Base): + __tablename__ = 'test_case' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + module_id = Column(BigInteger, comment='模块id') + case_key = Column(String(64), nullable=False, comment='项目内唯一编号') + title = Column(String(255), nullable=False, comment='标题') + preconditions = Column(Text, comment='前置条件') + steps = Column(Text, comment='步骤') + expected_results = Column(Text, comment='预期结果') + priority = Column(SmallInteger, default=2, comment='0:P0 1:P1 2:P2 3:P3') + case_type = Column(SmallInteger, default=1, comment='1:功能 2:性能 3:安全 4:接口') + tags = Column(ARRAY(String(64)), server_default=text("'{}'::varchar[]"), comment='标签') + status = Column(SmallInteger, default=1, comment='1:正常 2:已废弃 3:评审中 4:评审通过') + is_auto = Column(Integer, default=0, comment='0:未实现自动化;1:已实现自动化') + created_by = Column(BigInteger, comment='创建人') + 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='修改时间') + + +class CaseSnapshot(Base): + __tablename__ = 'case_snapshot' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + case_id = Column(BigInteger, nullable=False, comment='用例id') + version = Column(Integer, nullable=False, comment='版本') + snapshot = Column(JSONB, nullable=False, comment='快照') + created_by = Column(BigInteger, comment='创建人') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + + +class CaseReview(Base): + __tablename__ = 'case_review' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + case_id = Column(BigInteger, nullable=False, comment='用例id') + reviewer_id = Column(BigInteger, nullable=False, comment='评审人') + status = Column(SmallInteger, default=0, comment='0:待评审 1:通过 2:驳回 3:建议修改') + comments = Column(Text, comment='评论') + diff_content = Column(Text, comment='JSON diff') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + reviewed_time = Column(TIMESTAMP, nullable=True, comment='评审时间') diff --git a/app/api/model/dataBuilderModel.py b/app/api/model/dataBuilderModel.py new file mode 100644 index 0000000..d06e653 --- /dev/null +++ b/app/api/model/dataBuilderModel.py @@ -0,0 +1,38 @@ +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 DataBuilder(Base): + __tablename__ = 'data_builder' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + name = Column(String(128), nullable=False, comment='造数器名称') + description = Column(Text, comment='描述') + builder_type = Column(SmallInteger, default=1, comment='1:流程编排 2:SQL 3:脚本') + definition = Column(JSONB, nullable=False, comment='构造定义') + input_schema = Column(JSONB, comment='输入定义') + output_example = Column(JSONB, comment='输出示例') + created_by = Column(BigInteger, comment='创建人') + 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='修改时间') + + +class DataTask(Base): + __tablename__ = 'data_task' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + builder_id = Column(BigInteger, nullable=False, comment='造数器id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + params = Column(JSONB, comment='任务参数') + status = Column(SmallInteger, default=0, comment='0:等待 1:执行中 2:成功 3:失败') + result_data = Column(JSONB, comment='生成数据') + error_message = Column(Text, comment='错误信息') + created_by = Column(BigInteger, comment='创建人') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + completed_time = Column(TIMESTAMP, nullable=True, comment='完成时间') diff --git a/app/api/model/planModel.py b/app/api/model/planModel.py new file mode 100644 index 0000000..048d5ce --- /dev/null +++ b/app/api/model/planModel.py @@ -0,0 +1,50 @@ +from sqlalchemy import BigInteger, Column, Date, 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 TestPlan(Base): + __tablename__ = 'test_plan' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + name = Column(String(128), nullable=False, comment='计划名称') + version = Column(String(32), comment='测试版本') + description = Column(Text, comment='描述') + start_date = Column(Date, comment='开始日期') + end_date = Column(Date, comment='结束日期') + owner_id = Column(BigInteger, comment='负责人') + status = Column(SmallInteger, default=0, comment='0:草稿 1:进行中 2:已完成 3:已归档 4:已通过') + environment_id = Column(BigInteger, comment='环境id') + 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='修改时间') + + +class PlanCase(Base): + __tablename__ = 'plan_case' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + plan_id = Column(BigInteger, nullable=False, comment='计划id') + case_id = Column(BigInteger, nullable=False, comment='用例id') + assignee_id = Column(BigInteger, comment='执行人') + round_no = Column(Integer, default=1, comment='执行轮次') + status = Column(SmallInteger, default=0, comment='0:未开始 1:通过 2:失败 3:阻塞') + actual_result = Column(Text, comment='实际结果') + defect_links = Column(JSONB, server_default=text("'[]'::jsonb"), comment='缺陷链接') + attachments = Column(JSONB, server_default=text("'[]'::jsonb"), comment='附件') + executed_time = Column(TIMESTAMP, comment='执行时间') + execution_duration = Column(Integer, comment='执行耗时') + +role_name_map = {1: '测试经理', 2: '测试工程师', 3: '开发工程师', 4: '访客'} +class TestRound(Base): + __tablename__ = 'test_round' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + plan_id = Column(BigInteger, nullable=False, comment='计划id') + round_no = Column(Integer, nullable=False, comment='轮次') + name = Column(String(64), comment='轮次名称') + start_date = Column(Date, comment='开始日期') + end_date = Column(Date, comment='结束日期') diff --git a/app/api/model/productModel.py b/app/api/model/productModel.py new file mode 100644 index 0000000..736d4f5 --- /dev/null +++ b/app/api/model/productModel.py @@ -0,0 +1,19 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, TIMESTAMP, Text, text +from sqlalchemy.ext.declarative import declarative_base + +from common.sqlSession import to_dict + +Base = declarative_base() +Base.to_dict = to_dict + + +class Product(Base): + __tablename__ = 'product' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + name = Column(String(128), nullable=False, comment='产品名称') + code = Column(String(64), unique=True, nullable=False, comment='产品编码') + description = Column(Text, comment='产品描述') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + updated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), server_onupdate=text('CURRENT_TIMESTAMP'), nullable=True, comment='修改时间') diff --git a/app/api/model/projectHookModel.py b/app/api/model/projectHookModel.py new file mode 100644 index 0000000..7d5702b --- /dev/null +++ b/app/api/model/projectHookModel.py @@ -0,0 +1,23 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, Text, TIMESTAMP, 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 ProjectHook(Base): + __tablename__ = 'project_hook' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + hook_type = Column(SmallInteger, nullable=False, comment='1:飞书 2:钉钉 3:企微') + webhook_url = Column(String(512), nullable=False, comment='webhook地址') + secret = Column(String(256), comment='签名密钥') + enabled = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + description = Column(String(256), comment='描述') + config = Column(JSONB, server_default=text("'{}'::jsonb"), comment='扩展配置') + 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='修改时间') \ No newline at end of file diff --git a/app/api/model/projectModel.py b/app/api/model/projectModel.py new file mode 100644 index 0000000..f7abc6e --- /dev/null +++ b/app/api/model/projectModel.py @@ -0,0 +1,44 @@ +from sqlalchemy import BigInteger, Boolean, 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 Project(Base): + __tablename__ = 'project' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + key = Column(String(32), unique=True, nullable=False, comment='项目唯一标识') + name = Column(String(128), nullable=False, comment='项目名称') + product_id = Column(Integer, comment='产品id') + description = Column(Text, comment='项目描述') + department = Column(String(64), comment='部门') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + config = Column(JSONB, server_default=text("'{}'::jsonb"), comment='扩展配置') + created_by = Column(BigInteger, comment='创建人') + 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='修改时间') + + +class ProjectMember(Base): + __tablename__ = 'project_member' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + user_id = Column(BigInteger, nullable=False, comment='用户id') + role = Column(SmallInteger, nullable=False, comment='1:测试经理 2:测试工程师 3:开发工程师 4:访客') + joined_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='加入时间') + + +class Environment(Base): + __tablename__ = 'environment' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + name = Column(String(64), nullable=False, comment='环境名称,如 dev/st/pre/prod') + variables = Column(JSONB, nullable=False, comment='环境变量') + is_encrypted = Column(Boolean, default=False, comment='是否加密') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') diff --git a/app/api/model/rbacModel.py b/app/api/model/rbacModel.py new file mode 100644 index 0000000..6a7ea4f --- /dev/null +++ b/app/api/model/rbacModel.py @@ -0,0 +1,72 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, TIMESTAMP, Text, text +from sqlalchemy.ext.declarative import declarative_base + +from common.sqlSession import to_dict + +Base = declarative_base() +Base.to_dict = to_dict + + +class Role(Base): + __tablename__ = 'role' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + code = Column(String(64), unique=True, nullable=False, comment='角色编码') + name = Column(String(64), nullable=False, comment='角色名称') + description = Column(Text, comment='角色描述') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + is_system = Column(SmallInteger, default=0, comment='是否系统内置角色') + created_by = Column(BigInteger, comment='创建人') + 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='修改时间') + + +class Permission(Base): + __tablename__ = 'permission' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + code = Column(String(128), unique=True, nullable=False, comment='权限编码') + name = Column(String(128), nullable=False, comment='权限名称') + module = Column(String(64), comment='所属模块') + action = Column(String(64), comment='动作') + description = Column(Text, comment='描述') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + 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='修改时间') + + +class RolePermission(Base): + __tablename__ = 'role_permission' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + role_id = Column(BigInteger, nullable=False, comment='角色id') + permission_id = Column(BigInteger, nullable=False, comment='权限id') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') + + +class Menu(Base): + __tablename__ = 'menu' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + parent_id = Column(BigInteger, default=0, comment='父菜单id') + name = Column(String(64), nullable=False, comment='菜单名称') + code = Column(String(64), unique=True, comment='菜单编码') + type = Column(SmallInteger, default=1, comment='1:目录 2:菜单 3:按钮') + path = Column(String(255), comment='路由路径') + component = Column(String(255), comment='前端组件路径') + icon = Column(String(64), comment='图标') + permission_code = Column(String(128), comment='对应权限编码') + sort = Column(Integer, default=0, comment='排序') + visible = Column(SmallInteger, default=1, comment='1:显示 0:隐藏') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + 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='修改时间') + + +class RoleMenu(Base): + __tablename__ = 'role_menu' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + role_id = Column(BigInteger, nullable=False, comment='角色id') + menu_id = Column(BigInteger, nullable=False, comment='菜单id') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') diff --git a/app/api/model/reportModel.py b/app/api/model/reportModel.py new file mode 100644 index 0000000..73ab25e --- /dev/null +++ b/app/api/model/reportModel.py @@ -0,0 +1,34 @@ +from sqlalchemy import BigInteger, Column, 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 Report(Base): + __tablename__ = 'report' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + plan_id = Column(BigInteger, nullable=False, comment='计划id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + product_id = Column(BigInteger, nullable=False, comment='产品id') + name = Column(String(128), nullable=False, comment='报告名称') + report_type = Column(SmallInteger, default=1, comment='1:实时报告 2:归档报告') + summary = Column(JSONB, comment='统计数据') + content = Column(Text, comment='HTML内容') + file_url = Column(String(512), comment='文件地址') + generated_by = Column(BigInteger, comment='生成人') + generated_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='生成时间') + + +class DefectSync(Base): + __tablename__ = 'defect_sync' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + project_id = Column(BigInteger, nullable=False, comment='项目id') + external_id = Column(String(64), nullable=False, comment='外部缺陷id') + external_system = Column(String(32), comment='外部系统') + plan_case_id = Column(BigInteger, comment='计划用例id') + status = Column(String(32), comment='外部状态') + last_sync_time = Column(TIMESTAMP, comment='最后同步时间') diff --git a/app/api/model/updateSqlProjectModel.py b/app/api/model/updateSqlProjectModel.py index 8abc7bc..f0987c5 100644 --- a/app/api/model/updateSqlProjectModel.py +++ b/app/api/model/updateSqlProjectModel.py @@ -25,6 +25,3 @@ class UpdateSqlProject(Base): nullable=True, comment='修改时间' ) - - def __repr__(self): - return '' % self.id diff --git a/app/api/model/userModel.py b/app/api/model/userModel.py new file mode 100644 index 0000000..ed7c445 --- /dev/null +++ b/app/api/model/userModel.py @@ -0,0 +1,33 @@ +from sqlalchemy import BigInteger, Column, Integer, SmallInteger, String, TIMESTAMP, text +from sqlalchemy.ext.declarative import declarative_base + +from common.sqlSession import to_dict + +Base = declarative_base() +Base.to_dict = to_dict + + +class User(Base): + __tablename__ = 'user' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + username = Column(String(64), unique=True, nullable=False, comment='登录用户名') + real_name = Column(String(64), comment='真实姓名') + password_hash = Column(String(255), nullable=False, comment='密码哈希') + mobile = Column(String(32), comment='手机号') + email = Column(String(128), comment='邮箱') + avatar = Column(String(255), comment='头像地址') + status = Column(SmallInteger, default=1, comment='1:启用 0:禁用') + last_login_time = Column(TIMESTAMP, nullable=True, comment='最后登录时间') + created_by = Column(BigInteger, comment='创建人') + 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='修改时间') + + +class UserRole(Base): + __tablename__ = 'user_role' + id = Column(BigInteger, primary_key=True, autoincrement=True, comment='id') + user_id = Column(BigInteger, nullable=False, comment='用户id') + role_id = Column(BigInteger, nullable=False, comment='角色id') + is_delete = Column(Integer, default=0, comment='0:未删除;1:已删除') + created_time = Column(TIMESTAMP, server_default=text('CURRENT_TIMESTAMP'), nullable=True, comment='创建时间') diff --git a/app/api/service/__pycache__/__init__.cpython-38.pyc b/app/api/service/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..f6027eb Binary files /dev/null and b/app/api/service/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/bugService.cpython-38.pyc b/app/api/service/__pycache__/bugService.cpython-38.pyc new file mode 100644 index 0000000..12417b3 Binary files /dev/null and b/app/api/service/__pycache__/bugService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/caseService.cpython-38.pyc b/app/api/service/__pycache__/caseService.cpython-38.pyc new file mode 100644 index 0000000..b7bd2ed Binary files /dev/null and b/app/api/service/__pycache__/caseService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/dataBuilderService.cpython-38.pyc b/app/api/service/__pycache__/dataBuilderService.cpython-38.pyc new file mode 100644 index 0000000..9fcf62f Binary files /dev/null and b/app/api/service/__pycache__/dataBuilderService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/planService.cpython-38.pyc b/app/api/service/__pycache__/planService.cpython-38.pyc new file mode 100644 index 0000000..fa2a99c Binary files /dev/null and b/app/api/service/__pycache__/planService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/productService.cpython-38.pyc b/app/api/service/__pycache__/productService.cpython-38.pyc new file mode 100644 index 0000000..93c7891 Binary files /dev/null and b/app/api/service/__pycache__/productService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/projectHookService.cpython-38.pyc b/app/api/service/__pycache__/projectHookService.cpython-38.pyc new file mode 100644 index 0000000..59f3fd8 Binary files /dev/null and b/app/api/service/__pycache__/projectHookService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/projectService.cpython-38.pyc b/app/api/service/__pycache__/projectService.cpython-38.pyc new file mode 100644 index 0000000..d71ff9e Binary files /dev/null and b/app/api/service/__pycache__/projectService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/rbacService.cpython-38.pyc b/app/api/service/__pycache__/rbacService.cpython-38.pyc new file mode 100644 index 0000000..0b539cd Binary files /dev/null and b/app/api/service/__pycache__/rbacService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/reportService.cpython-38.pyc b/app/api/service/__pycache__/reportService.cpython-38.pyc new file mode 100644 index 0000000..603009b Binary files /dev/null and b/app/api/service/__pycache__/reportService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/updateSqlProjectService.cpython-38.pyc b/app/api/service/__pycache__/updateSqlProjectService.cpython-38.pyc new file mode 100644 index 0000000..544e0bc Binary files /dev/null and b/app/api/service/__pycache__/updateSqlProjectService.cpython-38.pyc differ diff --git a/app/api/service/__pycache__/userService.cpython-38.pyc b/app/api/service/__pycache__/userService.cpython-38.pyc new file mode 100644 index 0000000..eec076d Binary files /dev/null and b/app/api/service/__pycache__/userService.cpython-38.pyc differ diff --git a/app/api/service/bugService.py b/app/api/service/bugService.py new file mode 100644 index 0000000..90a0d4c --- /dev/null +++ b/app/api/service/bugService.py @@ -0,0 +1,55 @@ +# encoding: UTF-8 +from ..dao.bugDao import BugDao +from ..model.bugModel import BugComment + + +class BugService(object): + """Bug 管理 Service 层""" + + @staticmethod + def create(session, model_cls, add_info): + return BugDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return BugDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return BugDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None, asc=False): + return BugDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column, asc) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return BugDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def get_comments(session, bug_id): + return BugDao.get_comments(session, bug_id) + + @staticmethod + def get_history(session, bug_id): + return BugDao.get_history(session, bug_id) + + @staticmethod + def add_comment(session, bug_id, content, user_id): + return BugDao.create(session, BugComment, { + 'bug_id': bug_id, + 'content': content, + 'user_id': user_id + }) + + @staticmethod + def generate_bug_key(session): + return BugDao.generate_bug_key(session) + + @staticmethod + def get_stats(session, product_id=None, project_id=None): + return BugDao.get_stats(session, product_id, project_id) + + @staticmethod + def add_history(session, bug_id, field_name, old_value, new_value, operator_id): + return BugDao.add_history(session, bug_id, field_name, old_value, new_value, operator_id) \ No newline at end of file diff --git a/app/api/service/caseService.py b/app/api/service/caseService.py new file mode 100644 index 0000000..724d026 --- /dev/null +++ b/app/api/service/caseService.py @@ -0,0 +1,38 @@ +# encoding: UTF-8 +from ..dao.caseDao import CaseDao + + +class CaseService(object): + """用例域 Service 层,封装用例编号和快照版本等业务能力。""" + + @staticmethod + def create(session, model_cls, add_info): + return CaseDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return CaseDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return CaseDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return CaseDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + 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) + + @staticmethod + def next_snapshot_version(session, case_id): + return CaseDao.next_snapshot_version(session, case_id) + + @staticmethod + def get_module_name_map(session, module_ids): + return CaseDao.get_module_name_map(session, module_ids) diff --git a/app/api/service/dataBuilderService.py b/app/api/service/dataBuilderService.py new file mode 100644 index 0000000..b1e3b50 --- /dev/null +++ b/app/api/service/dataBuilderService.py @@ -0,0 +1,63 @@ +# encoding: UTF-8 +from datetime import datetime + +from common.dataBuilderExecutor import DataBuilderExecutor +from ..dao.dataBuilderDao import DataBuilderDao +from ..model.dataBuilderModel import DataBuilder, DataTask + + +class DataBuilderService(object): + @staticmethod + def create(session, model_cls, add_info): + return DataBuilderDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return DataBuilderDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return DataBuilderDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return DataBuilderDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return DataBuilderDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def execute_builder(session, builder_id, params=None, created_by=None): + builder = DataBuilderDao.get_by_id(session, DataBuilder, builder_id) + if not builder: + return {}, '未查询到对应造数器!' + params = params or {} + task_info = { + 'builder_id': builder.id, + 'project_id': builder.project_id, + 'params': params, + 'status': 1, + 'created_by': created_by + } + # 先写入执行中任务,保证失败时也能追踪任务记录。 + task_id, err_msg = DataBuilderDao.create(session, DataTask, task_info) + if err_msg: + return {}, err_msg + try: + # 当前 MVP 只做同步模板渲染执行,后续可在 executor 内扩展 http/db step。 + executor = DataBuilderExecutor(builder.definition or {}, {}) + result_data = executor.execute(params) + DataBuilderDao.update_by_id(session, DataTask, task_id, { + 'status': 2, + 'result_data': result_data, + 'completed_time': datetime.now() + }, soft_delete=False) + return {'taskId': task_id, 'data': result_data}, '' + except Exception as e: + DataBuilderDao.update_by_id(session, DataTask, task_id, { + 'status': 3, + 'error_message': str(e), + 'completed_time': datetime.now() + }, soft_delete=False) + return {}, f'执行造数失败!{e}' diff --git a/app/api/service/planService.py b/app/api/service/planService.py new file mode 100644 index 0000000..845a837 --- /dev/null +++ b/app/api/service/planService.py @@ -0,0 +1,34 @@ +# encoding: UTF-8 +from ..dao.planDao import PlanDao + + +class PlanService(object): + """测试计划域 Service 层,封装计划统计等业务能力。""" + + @staticmethod + def create(session, model_cls, add_info): + return PlanDao.create(session, model_cls, add_info) + + @staticmethod + def batch_create(session, model_cls, batch_info_list): + return PlanDao.batch_create(session, model_cls, batch_info_list) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return PlanDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return PlanDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None, asc=False): + return PlanDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column, asc) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return PlanDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def plan_stats(session, plan_id): + return PlanDao.plan_stats(session, plan_id) diff --git a/app/api/service/productService.py b/app/api/service/productService.py new file mode 100644 index 0000000..591c82b --- /dev/null +++ b/app/api/service/productService.py @@ -0,0 +1,24 @@ +# encoding: UTF-8 +from ..dao.productDao import ProductDao + + +class ProductService(object): + @staticmethod + def create(session, model_cls, add_info): + return ProductDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return ProductDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return ProductDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return ProductDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProductDao.delete_by_id(session, model_cls, obj_id) diff --git a/app/api/service/projectHookService.py b/app/api/service/projectHookService.py new file mode 100644 index 0000000..874e8d3 --- /dev/null +++ b/app/api/service/projectHookService.py @@ -0,0 +1,36 @@ +# encoding: UTF-8 +from ..dao.projectHookDao import ProjectHookDao +from ..model.projectHookModel import ProjectHook + + +class ProjectHookService(object): + @staticmethod + def create(session, model_cls, add_info): + return ProjectHookDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return ProjectHookDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return ProjectHookDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return ProjectHookDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProjectHookDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def get_hooks_by_project(session, project_id, hook_type=None): + filters = [ + ProjectHook.project_id == int(project_id), + ProjectHook.is_delete == 0, + ProjectHook.enabled == 1 + ] + if hook_type not in (None, ''): + filters.append(ProjectHook.hook_type == int(hook_type)) + return ProjectHookDao.list_all_by_filters(session, ProjectHook, filters) \ No newline at end of file diff --git a/app/api/service/projectService.py b/app/api/service/projectService.py new file mode 100644 index 0000000..b64686c --- /dev/null +++ b/app/api/service/projectService.py @@ -0,0 +1,34 @@ +# encoding: UTF-8 +from ..dao.projectDao import ProjectDao + + +class ProjectService(object): + """项目域 Service 层,保持业务入口与 DAO 解耦。""" + + @staticmethod + def create(session, model_cls, add_info): + return ProjectDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return ProjectDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return ProjectDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return ProjectDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return ProjectDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def get_product_map(session, product_ids): + return ProjectDao.get_product_map(session, product_ids) + + @staticmethod + def get_project_name_map(session, project_ids): + return ProjectDao.get_project_name_map(session, project_ids) diff --git a/app/api/service/rbacService.py b/app/api/service/rbacService.py new file mode 100644 index 0000000..0a8abf7 --- /dev/null +++ b/app/api/service/rbacService.py @@ -0,0 +1,103 @@ +# encoding: UTF-8 +from ..dao.rbacDao import RbacDao + + +def has_permission(permission_code, permission_codes): + if not permission_code: + return True + if not permission_codes: + return False + if permission_code in permission_codes: + return True + if '*:*' in permission_codes: + return True + if ':' in permission_code: + module_code = permission_code.split(':', 1)[0] + if f'{module_code}:*' in permission_codes: + return True + if '_' in module_code: + parent_module_code = module_code.split('_', 1)[0] + if f'{parent_module_code}:*' in permission_codes: + return True + return False + + +class RbacService(object): + @staticmethod + def create(session, model_cls, add_info): + return RbacDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return RbacDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return RbacDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return RbacDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return RbacDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def assign_permissions(session, role_ids, permission_id): + return RbacDao.assign_permissions_to_roles(session, role_ids, permission_id) + + @staticmethod + def assign_menus(session, role_id, menu_ids): + return RbacDao.replace_role_menus(session, role_id, menu_ids) + + @staticmethod + def get_role_permission_ids(session, role_id): + return RbacDao.get_role_permission_ids(session, role_id) + + @staticmethod + def get_role_menu_ids(session, role_id): + return RbacDao.get_role_menu_ids(session, role_id) + + @staticmethod + def build_menu_tree(session, filters, role_ids=None, menu_ids=None): + items = RbacDao.get_menu_tree_items(session, filters) + visible_ids = set() + if not role_ids and not menu_ids: + visible_ids = {item.id for item in items} + else: + role_menu_ids = set(menu_ids or []) + if role_ids: + for role_id in role_ids: + role_menu_ids.update(RbacDao.get_role_menu_ids(session, role_id)) + visible_ids = set(role_menu_ids) + item_by_id = {item.id: item for item in items} + for item_id in list(visible_ids): + if item_id not in item_by_id: + continue + parent_id = item_by_id[item_id].parent_id + while parent_id and parent_id in item_by_id: + if parent_id in visible_ids: + break + visible_ids.add(parent_id) + parent_id = item_by_id[parent_id].parent_id + item_map = {} + roots = [] + for item in items: + if item.id not in visible_ids: + continue + item_dict = item.to_dict() + item_dict['children'] = [] + item_map[item.id] = item_dict + for item in items: + if item.id not in item_map: + continue + if item.parent_id and item.parent_id in item_map: + item_map[item.parent_id]['children'].append(item_map[item.id]) + else: + roots.append(item_map[item.id]) + return roots + + @staticmethod + def get_role_permission_codes(session, role_ids): + return RbacDao.get_role_permission_codes(session, role_ids) diff --git a/app/api/service/reportService.py b/app/api/service/reportService.py new file mode 100644 index 0000000..6a8141a --- /dev/null +++ b/app/api/service/reportService.py @@ -0,0 +1,46 @@ +# encoding: UTF-8 +from ..dao.planDao import PlanDao +from ..dao.projectDao import ProjectDao +from ..dao.reportDao import ReportDao +from ..model.planModel import TestPlan +from ..model.reportModel import Report + + +class ReportService(object): + @staticmethod + def create(session, model_cls, add_info): + return ReportDao.create(session, model_cls, add_info) + + @staticmethod + def get_by_id(session, model_cls, obj_id): + return ReportDao.get_by_id(session, model_cls, obj_id) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None, asc=False): + return ReportDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column, asc) + + @staticmethod + def generate_report(session, plan_id, generated_by=None): + plan = PlanDao.get_by_id(session, TestPlan, plan_id) + if not plan: + return 0, '未查询到对应计划!' + project = ProjectDao.get_by_id(session, ProjectDao.project_model(), plan.project_id) + if not project: + return 0, '未查询到对应项目!' + # 复用计划统计,保证计划详情和报告中的指标口径一致。 + stats = PlanDao.plan_stats(session, plan_id) + # MVP 阶段先生成简单 HTML,后续可替换为模板渲染器。 + content = '

{}

总用例:{}

通过率:{}%

'.format( + plan.name, stats['total_cases'], stats['pass_rate'] + ) + add_info = { + 'plan_id': int(plan_id), + 'project_id': plan.project_id, + 'product_id': project.product_id, + 'name': '{}_报告'.format(plan.name), + 'report_type': 1, + 'summary': stats, + 'content': content, + 'generated_by': generated_by + } + return ReportDao.create(session, Report, add_info) diff --git a/app/api/service/userService.py b/app/api/service/userService.py new file mode 100644 index 0000000..a06cb7a --- /dev/null +++ b/app/api/service/userService.py @@ -0,0 +1,58 @@ +# encoding: UTF-8 +from ..dao.userDao import UserDao +from ..dao.rbacDao import RbacDao + + +class UserService(object): + @staticmethod + def create(session, model_cls, add_info): + return UserDao.create(session, model_cls, add_info) + + @staticmethod + def update_by_id(session, model_cls, obj_id, update_info, soft_delete=True): + return UserDao.update_by_id(session, model_cls, obj_id, update_info, soft_delete) + + @staticmethod + def get_by_id(session, model_cls, obj_id, soft_delete=True): + return UserDao.get_by_id(session, model_cls, obj_id, soft_delete) + + @staticmethod + def list_by_filters(session, model_cls, filter_list, page_num=1, page_size=20, order_column=None): + return UserDao.list_by_filters(session, model_cls, filter_list, int(page_num), int(page_size), order_column) + + @staticmethod + def delete_by_id(session, model_cls, obj_id): + return UserDao.delete_by_id(session, model_cls, obj_id) + + @staticmethod + def assign_roles(session, user_id, role_ids): + return UserDao.replace_user_roles(session, user_id, role_ids) + + @staticmethod + def get_user_role_ids(session, user_id): + return UserDao.get_user_role_ids(session, user_id) + + @staticmethod + def get_user_roles_map(session, user_ids): + user_role_map = UserDao.get_user_roles(session, user_ids) + role_ids = list({role_id for role_list in user_role_map.values() for role_id in role_list}) + role_name_map = RbacDao.get_role_names_map(session, role_ids) + ret = {} + for user_id, ids in user_role_map.items(): + ret[user_id] = { + 'role_ids': ids, + 'role_names': [role_name_map.get(role_id, '') for role_id in ids] + } + return ret + + @staticmethod + def get_by_username(session, username): + return UserDao.get_by_username(session, username) + + @staticmethod + def get_user_info_map(session, user_ids): + return UserDao.get_user_info_map(session, user_ids) + + @staticmethod + def update_last_login_time(session, user_id): + return UserDao.update_last_login_time(session, user_id) diff --git a/app/api/utils/__pycache__/__init__.cpython-38.pyc b/app/api/utils/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..c7db1d4 Binary files /dev/null and b/app/api/utils/__pycache__/__init__.cpython-38.pyc differ diff --git a/app/api/utils/__pycache__/authMiddleware.cpython-38.pyc b/app/api/utils/__pycache__/authMiddleware.cpython-38.pyc new file mode 100644 index 0000000..464478e Binary files /dev/null and b/app/api/utils/__pycache__/authMiddleware.cpython-38.pyc differ diff --git a/app/api/utils/authMiddleware.py b/app/api/utils/authMiddleware.py new file mode 100644 index 0000000..35a7ce4 --- /dev/null +++ b/app/api/utils/authMiddleware.py @@ -0,0 +1,152 @@ +# encoding: UTF-8 +import json +import uuid +from functools import wraps + +import redis +from sqlalchemy.exc import OperationalError +from flask import request, g + +from const import REDIS_URL +from common.apiResponse import ApiResponse +from ..service.userService import UserService +from ..service.rbacService import RbacService +from ..model.userModel import User +from common.sqlSession import SqlSession + +TOKEN_PREFIX = 'effekt:token:' +TOKEN_CONTEXT_PREFIX = 'effekt:token:ctx:' +TOKEN_EXPIRE_SECONDS = 7200 +TOKEN_REFRESH_THRESHOLD_SECONDS = 1800 +TOKEN_CONTEXT_EXPIRE_SECONDS = 300 +WHITELIST_PATHS = ['/it/api/auth/login', '/it/api/auth/register'] + +_redis_client = redis.from_url(REDIS_URL, decode_responses=True) +_redis_client.ping() + + +def create_token(user_id): + token = uuid.uuid4().hex + key = TOKEN_PREFIX + token + _redis_client.setex(key, TOKEN_EXPIRE_SECONDS, str(user_id)) + return token, TOKEN_EXPIRE_SECONDS + + +def get_token_ttl(token): + return _redis_client.ttl(TOKEN_PREFIX + token) + + +def refresh_token_if_needed(token): + ttl = get_token_ttl(token) + if ttl != -2 and ttl < TOKEN_REFRESH_THRESHOLD_SECONDS: + _redis_client.expire(TOKEN_PREFIX + token, TOKEN_EXPIRE_SECONDS) + return TOKEN_EXPIRE_SECONDS + return ttl + + +def get_current_user_id(token): + user_id = _redis_client.get(TOKEN_PREFIX + token) + return int(user_id) if user_id else 0 + + +def parse_token(): + return request.headers.get('accessToken') or request.headers.get('accesstoken') or request.headers.get('Authorization', '').replace('Bearer ', '') + + +def get_token_context(token): + context_str = _redis_client.get(TOKEN_CONTEXT_PREFIX + token) + return json.loads(context_str) if context_str else None + + +def cache_token_context(token, user, role_ids, permission_codes): + _redis_client.setex(TOKEN_CONTEXT_PREFIX + token, TOKEN_CONTEXT_EXPIRE_SECONDS, json.dumps({ + 'user': user.to_dict(), + 'role_ids': role_ids, + 'permission_codes': permission_codes + }, default=str)) + + +def login_required(func): + @wraps(func) + def wrapper(*args, **kwargs): + token = parse_token() + if not token: + return ApiResponse.build_failure(40004, msg='未登录或缺少token!') + user_id = get_current_user_id(token) + if not user_id: + return ApiResponse.build_failure(40004, msg='token无效或已过期!') + session = None + try: + token_context = get_token_context(token) + if token_context: + g.current_user_id = user_id + g.current_user = token_context.get('user', {}) + g.current_role_ids = token_context.get('role_ids', []) + g.current_permission_codes = token_context.get('permission_codes', []) + g.current_token = token + g.current_token_ttl = refresh_token_if_needed(token) + return func(*args, **kwargs) + session = SqlSession() + user = UserService.get_by_id(session, User, user_id) + if not user: + return ApiResponse.build_failure(40011, msg='未查询到对应用户!') + role_ids = UserService.get_user_role_ids(session, user_id) + permission_codes = RbacService.get_role_permission_codes(session, role_ids) + cache_token_context(token, user, role_ids, permission_codes) + g.current_user_id = user_id + g.current_user = user + g.current_role_ids = role_ids + g.current_permission_codes = permission_codes + g.current_token = token + g.current_token_ttl = refresh_token_if_needed(token) + return func(*args, **kwargs) + except OperationalError: + return ApiResponse.build_failure(40008, msg='数据库连接超时,请稍后重试!') + finally: + if session: + session.close() + return wrapper + + +def has_permission(permission_code, permission_codes): + if not permission_code: + return True + if not permission_codes: + return False + if permission_code in permission_codes: + return True + if '*:*' in permission_codes: + return True + if ':' in permission_code: + module_code = permission_code.split(':', 1)[0] + if f'{module_code}:*' in permission_codes: + return True + if '_' in module_code: + parent_module_code = module_code.split('_', 1)[0] + if f'{parent_module_code}:*' in permission_codes: + return True + return False + + +def permission_required(permission_code): + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + if not getattr(g, 'current_user_id', None): + return ApiResponse.build_failure(40004, 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 func(*args, **kwargs) + return wrapper + return decorator + + +def should_skip_auth(path): + return path in WHITELIST_PATHS + + +def logout_token(token): + if token: + _redis_client.delete(TOKEN_PREFIX + token) + _redis_client.delete(TOKEN_CONTEXT_PREFIX + token) diff --git a/app/api/views.py b/app/api/views.py index f2c6412..c0b73b0 100644 --- a/app/api/views.py +++ b/app/api/views.py @@ -1,20 +1,47 @@ # encoding: UTF-8 +from sqlalchemy.exc import OperationalError from flask import Blueprint, request +import traceback from common.apiResponse import ApiResponse +from logger import logger +from .utils.authMiddleware import login_required, permission_required, should_skip_auth from .controller.updateSqlProjectController import UpdateSqlProjectController - +from .controller.projectController import ProjectController +from .controller.caseController import CaseController +from .controller.planController import PlanController +from .controller.reportController import ReportController +from .controller.dataBuilderController import DataBuilderController +from .controller.productController import ProductController +from .controller.rbacController import RbacController +from .controller.userController import UserController +from .controller.bugController import BugController, BugUploadController +from .controller.projectHookController import ProjectHookController api = Blueprint('api', __name__) +@api.before_request +def api_before_request(): + if request.method == 'OPTIONS' or should_skip_auth(request.path): + 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 None + + @api.route('/list', methods=['GET']) +@login_required +@permission_required('sql_project:list') def get_list(): request_args = request.args controller = UpdateSqlProjectController(request_args) try: ret = controller.query_smart_manage_sql_data() return ApiResponse.build_success(20000, data=ret) + except OperationalError: + return ApiResponse.build_failure(40008, msg='数据库连接超时,请稍后重试!') except Exception as e: from logger import logger logger.exception(f'get_list failed, args={dict(request_args)}, err={e}') @@ -22,6 +49,8 @@ def get_list(): @api.route('/create', methods=['POST']) +@login_required +@permission_required('sql_project:create') def create_sql_project(): req_json = request.get_json() or {} controller = UpdateSqlProjectController(req_json) @@ -32,6 +61,8 @@ def create_sql_project(): @api.route('/detail', methods=['GET']) +@login_required +@permission_required('sql_project:detail') def get_sql_project_detail(): request_args = request.args controller = UpdateSqlProjectController(request_args) @@ -42,6 +73,8 @@ def get_sql_project_detail(): @api.route('/delete', methods=['POST']) +@login_required +@permission_required('sql_project:delete') def delete_sql_project(): req_json = request.get_json() or {} controller = UpdateSqlProjectController(req_json) @@ -52,10 +85,1287 @@ def delete_sql_project(): @api.route('/execute', methods=['POST']) +@login_required +@permission_required('sql_project:execute') def execute_sql_project(): + """按 SQL 配置中的项目和环境执行目标 SQL。""" req_json = request.get_json() or {} controller = UpdateSqlProjectController(req_json) ret, err_msg = controller.execute_sql_project() if err_msg: return ApiResponse.build_failure(40009, msg=err_msg) return ApiResponse.build_success(20000, data=ret) + + +@api.route('/project/list', methods=['GET']) +@login_required +@permission_required('project:list') +def project_list(): + controller = ProjectController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.project_list()) + finally: + controller.close_session() + + +@api.route('/project/detail', methods=['GET']) +@login_required +@permission_required('project:detail') +def project_detail(): + controller = ProjectController(request.args) + try: + ret, err_msg = controller.project_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('/project/create', methods=['POST']) +@login_required +@permission_required('project:create') +def project_create(): + controller = ProjectController(request.get_json() or {}) + try: + create_id, err_msg = controller.project_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/project/update', methods=['POST']) +@login_required +@permission_required('project:update') +def project_update(): + controller = ProjectController(request.get_json() or {}) + try: + update_id, err_msg = controller.project_update() + 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('/project/delete', methods=['POST']) +@login_required +@permission_required('project:delete') +def project_delete(): + controller = ProjectController(request.get_json() or {}) + try: + delete_id, err_msg = controller.project_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/environment/list', methods=['GET']) +@login_required +@permission_required('environment:list') +def environment_list(): + """分页查询环境配置列表。""" + controller = ProjectController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.environment_list()) + finally: + controller.close_session() + + +@api.route('/environment/create', methods=['POST']) +@login_required +@permission_required('environment:create') +def environment_create(): + controller = ProjectController(request.get_json() or {}) + try: + create_id, err_msg = controller.environment_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/environment/update', methods=['POST']) +@login_required +@permission_required('environment:update') +def environment_update(): + controller = ProjectController(request.get_json() or {}) + try: + update_id, err_msg = controller.environment_update() + 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('/environment/delete', methods=['POST']) +@login_required +@permission_required('environment:delete') +def environment_delete(): + controller = ProjectController(request.get_json() or {}) + try: + delete_id, err_msg = controller.environment_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/project/member/list', methods=['GET']) +@login_required +@permission_required('project_member:list') +def project_member_list(): + controller = ProjectController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.member_list()) + finally: + controller.close_session() + + +@api.route('/project/member/create', methods=['POST']) +@login_required +@permission_required('project_member:create') +def project_member_create(): + """批量添加项目成员。""" + controller = ProjectController(request.get_json() or {}) + try: + result, err_msg = controller.member_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': result}) + finally: + controller.close_session() + + +@api.route('/project/hook/list', methods=['GET']) +@login_required +@permission_required('project_hook:list') +def project_hook_list(): + controller = ProjectHookController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.hook_list()) + finally: + controller.close_session() + + +@api.route('/project/hook/detail', methods=['GET']) +@login_required +@permission_required('project_hook:detail') +def project_hook_detail(): + controller = ProjectHookController(request.args) + try: + ret, err_msg = controller.hook_detail() + if err_msg: + return ApiResponse.build_failure(40016, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/project/hook/create', methods=['POST']) +@login_required +@permission_required('project_hook:create') +def project_hook_create(): + controller = ProjectHookController(request.get_json() or {}) + try: + hook_id, err_msg = controller.hook_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': hook_id}) + finally: + controller.close_session() + + +@api.route('/project/hook/update', methods=['POST']) +@login_required +@permission_required('project_hook:update') +def project_hook_update(): + controller = ProjectHookController(request.get_json() or {}) + try: + hook_id, err_msg = controller.hook_update() + if err_msg: + return ApiResponse.build_failure(40010, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': hook_id}) + finally: + controller.close_session() + + +@api.route('/project/hook/delete', methods=['POST']) +@login_required +@permission_required('project_hook:delete') +def project_hook_delete(): + controller = ProjectHookController(request.get_json() or {}) + try: + hook_id, err_msg = controller.hook_delete() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': hook_id}) + finally: + controller.close_session() + + +@api.route('/project/hook/send', methods=['POST']) +@login_required +@permission_required('project_hook:send') +def project_hook_send(): + controller = ProjectHookController(request.get_json() or {}) + try: + success, result = controller.hook_send() + if not success: + if isinstance(result, str): + return ApiResponse.build_failure(40012, msg=result) + elif isinstance(result, list) and result: + errors = [r.get('error') for r in result if not r.get('success') and r.get('error')] + error_msg = errors[0] if errors else '发送失败' + return ApiResponse.build_failure(40012, msg=error_msg, data=result) + else: + return ApiResponse.build_failure(40012, msg='发送失败', data=result) + return ApiResponse.build_success(20000, data=result) + finally: + controller.close_session() + + +@api.route('/product/list', methods=['GET']) +@login_required +@permission_required('product:list') +def product_list(): + controller = ProductController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.product_list()) + finally: + controller.close_session() + + +@api.route('/product/detail', methods=['GET']) +@login_required +@permission_required('product:detail') +def product_detail(): + controller = ProductController(request.args) + try: + ret, err_msg = controller.product_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('/product/create', methods=['POST']) +@login_required +@permission_required('product:create') +def product_create(): + controller = ProductController(request.get_json() or {}) + try: + create_id, err_msg = controller.product_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/product/update', methods=['POST']) +@login_required +@permission_required('product:update') +def product_update(): + controller = ProductController(request.get_json() or {}) + try: + update_id, err_msg = controller.product_update() + 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('/product/delete', methods=['POST']) +@login_required +@permission_required('product:delete') +def product_delete(): + controller = ProductController(request.get_json() or {}) + try: + delete_id, err_msg = controller.product_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/module/tree', methods=['GET']) +@login_required +@permission_required('module:list') +def module_tree(): + try: + return ApiResponse.build_success(20000, data=CaseController(request.args).module_list()) + except Exception as e: + logger.error(f'module_tree异常:{str(e)}, 请求参数:{dict(request.args)}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40009, msg=f'查询失败:{str(e)[:100]}') + + +@api.route('/module/create', methods=['POST']) +@login_required +@permission_required('module:create') +def module_create(): + try: + create_id, err_msg = CaseController(request.get_json() or {}).module_create() + if err_msg: + logger.warning(f'module_create失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + except Exception as e: + logger.error(f'module_create异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40009, msg=f'创建失败:{str(e)[:100]}') + + +@api.route('/module/update', methods=['POST']) +@login_required +@permission_required('module:update') +def module_update(): + try: + update_id, err_msg = CaseController(request.get_json() or {}).module_update() + if err_msg: + logger.warning(f'module_update失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + except Exception as e: + logger.error(f'module_update异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40012, msg=f'更新失败:{str(e)[:100]}') + + +@api.route('/module/delete', methods=['POST']) +@login_required +@permission_required('module:delete') +def module_delete(): + try: + delete_id, err_msg = CaseController(request.get_json() or {}).module_delete() + if err_msg: + logger.warning(f'module_delete失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + except Exception as e: + logger.error(f'module_delete异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40012, msg=f'删除失败:{str(e)[:100]}') + + +@api.route('/case/list', methods=['GET']) +@login_required +@permission_required('case:list') +def case_list(): + try: + controller = CaseController(request.args) + return ApiResponse.build_success(20000, data=controller.case_list()) + except Exception as e: + logger.error(f'case_list异常:{str(e)}, 请求参数:{dict(request.args)}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40009, msg=f'查询失败:{str(e)[:100]}') + + +@api.route('/case/detail', methods=['GET']) +@login_required +@permission_required('case:detail') +def case_detail(): + ret, err_msg = CaseController(request.args).case_detail() + if err_msg: + logger.warning(f'case_detail失败:{err_msg}, 请求参数:{dict(request.args)}') + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +@api.route('/case/create', methods=['POST']) +@login_required +@permission_required('case:create') +def case_create(): + try: + create_id, err_msg = CaseController(request.get_json() or {}).case_create() + if err_msg: + logger.warning(f'case_create失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + except Exception as e: + logger.error(f'case_create异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40009, msg=f'创建失败:{str(e)[:100]}') + + +@api.route('/case/update', methods=['POST']) +@login_required +@permission_required('case:update') +def case_update(): + try: + update_id, err_msg = CaseController(request.get_json() or {}).case_update() + if err_msg: + logger.warning(f'case_update失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + except Exception as e: + logger.error(f'case_update异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40012, msg=f'更新失败:{str(e)[:100]}') + + +@api.route('/case/delete', methods=['POST']) +@login_required +@permission_required('case:delete') +def case_delete(): + try: + delete_id, err_msg = CaseController(request.get_json() or {}).case_delete() + if err_msg: + logger.warning(f'case_delete失败:{err_msg}, 请求参数:{request.get_json()}') + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + except Exception as e: + logger.error(f'case_delete异常:{str(e)}, 请求参数:{request.get_json()}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40012, msg=f'删除失败:{str(e)[:100]}') + + +@api.route('/case/import', methods=['POST']) +@login_required +@permission_required('case:create') +def case_import(): + import os + from flask import send_file + + try: + if 'file' not in request.files: + logger.warning('case_import失败:请上传文件') + return ApiResponse.build_failure(40009, msg='请上传文件') + + file = request.files['file'] + if file.filename == '': + logger.warning('case_import失败:请选择文件') + return ApiResponse.build_failure(40009, msg='请选择文件') + + project_id = request.form.get('projectId') + if not project_id: + logger.warning('case_import失败:projectId 为必传参数') + return ApiResponse.build_failure(40009, msg='projectId 为必传参数') + + # 获取项目根目录 + root_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + attachment_dir = os.path.join(root_dir, 'attachment') + # 确保 attachment 目录存在 + os.makedirs(attachment_dir, exist_ok=True) + temp_path = os.path.join(attachment_dir, 'temp_import.xlsx') + file.save(temp_path) + + controller = CaseController({}) + try: + success_count, err_msg = controller.case_import(temp_path, project_id) + if err_msg and ('失败' in err_msg or success_count == 0): + logger.warning(f'case_import失败:{err_msg}, projectId={project_id}') + return ApiResponse.build_failure(40009, msg=err_msg) + logger.info(f'case_import成功:成功{success_count}条, projectId={project_id}') + return ApiResponse.build_success(20000, data={'successCount': success_count, 'message': err_msg}) + finally: + controller.close_session() + if os.path.exists(temp_path): + os.remove(temp_path) + except Exception as e: + logger.error(f'case_import异常:{str(e)}, projectId={request.form.get("projectId")}, 堆栈:{traceback.format_exc()}') + return ApiResponse.build_failure(40009, msg=f'导入失败:{str(e)[:100]}') + + +@api.route('/import/template', methods=['GET']) +@login_required +def import_template(): + import os + from flask import send_file + + template_path = CaseController.get_template_path() + if not os.path.exists(template_path): + return ApiResponse.build_failure(40011, msg='模板文件不存在') + + return send_file(template_path, as_attachment=True, attachment_filename='测试用例模版.xlsx') + + +@api.route('/case/snapshot/create', methods=['POST']) +@login_required +@permission_required('case_snapshot:create') +def case_snapshot_create(): + create_id, err_msg = CaseController(request.get_json() or {}).snapshot_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + + +@api.route('/case/snapshot/list', methods=['GET']) +@login_required +@permission_required('case_snapshot:list') +def case_snapshot_list(): + return ApiResponse.build_success(20000, data=CaseController(request.args).snapshot_list()) + + +@api.route('/case/review/create', methods=['POST']) +@login_required +@permission_required('case_review:create') +def case_review_create(): + create_id, err_msg = CaseController(request.get_json() or {}).review_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + + +@api.route('/case/review/update', methods=['POST']) +@login_required +@permission_required('case_review:update') +def case_review_update(): + update_id, err_msg = CaseController(request.get_json() or {}).review_update() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + + +@api.route('/case/review/list', methods=['GET']) +@login_required +@permission_required('case_review:list') +def case_review_list(): + return ApiResponse.build_success(20000, data=CaseController(request.args).review_list()) + + +@api.route('/plan/list', methods=['GET']) +@login_required +@permission_required('plan:list') +def plan_list(): + return ApiResponse.build_success(20000, data=PlanController(request.args).plan_list()) + + +@api.route('/plan/detail', methods=['GET']) +@login_required +@permission_required('plan:detail') +def plan_detail(): + ret, err_msg = PlanController(request.args).plan_detail() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +@api.route('/plan/create', methods=['POST']) +@login_required +@permission_required('plan:create') +def plan_create(): + create_id, err_msg = PlanController(request.get_json() or {}).plan_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + + +@api.route('/plan/update', methods=['POST']) +@login_required +@permission_required('plan:update') +def plan_update(): + update_id, err_msg = PlanController(request.get_json() or {}).plan_update() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + + +@api.route('/plan/delete', methods=['POST']) +@login_required +@permission_required('plan:delete') +def plan_delete(): + delete_id, err_msg = PlanController(request.get_json() or {}).plan_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + + +@api.route('/plan/round/create', methods=['POST']) +@login_required +@permission_required('plan_round:create') +def plan_round_create(): + create_id, err_msg = PlanController(request.get_json() or {}).round_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + + +@api.route('/plan/round/list', methods=['GET']) +@login_required +@permission_required('plan_round:list') +def plan_round_list(): + return ApiResponse.build_success(20000, data=PlanController(request.args).round_list()) + + +@api.route('/plan/case/add', methods=['POST']) +@login_required +@permission_required('plan_case:add') +def plan_case_add(): + added_count, err_msg = PlanController(request.get_json() or {}).plan_case_add() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'addedCount': added_count}) + + +@api.route('/plan/case/list', methods=['GET']) +@login_required +@permission_required('plan_case:list') +def plan_case_list(): + return ApiResponse.build_success(20000, data=PlanController(request.args).plan_case_list()) + + +@api.route('/plan/case/execute', methods=['POST']) +@login_required +@permission_required('plan_case:execute') +def plan_case_execute(): + update_id, err_msg = PlanController(request.get_json() or {}).plan_case_execute() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + + +@api.route('/plan/progress', methods=['GET']) +@login_required +@permission_required('plan:progress') +def plan_progress(): + ret, err_msg = PlanController(request.args).progress() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +# ========================= +# 报告接口 +# ========================= + + +@api.route('/report/list', methods=['GET']) +@login_required +@permission_required('report:list') +def report_list(): + """分页查询测试报告列表。""" + controller = ReportController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.report_list()) + finally: + controller.close_session() + + +@api.route('/report/detail', methods=['GET']) +@login_required +@permission_required('report:detail') +def report_detail(): + controller = ReportController(request.args) + try: + ret, err_msg = controller.report_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('/report/generate', methods=['POST']) +@login_required +@permission_required('report:generate') +def report_generate(): + controller = ReportController(request.get_json() or {}) + try: + create_id, err_msg = controller.report_generate() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +# ========================= +# 造数器与造数任务接口 +# ========================= + + +@api.route('/data/builder/list', methods=['GET']) +@login_required +@permission_required('data_builder:list') +def data_builder_list(): + """分页查询造数器列表。""" + return ApiResponse.build_success(20000, data=DataBuilderController(request.args).builder_list()) + + +@api.route('/data/builder/detail', methods=['GET']) +@login_required +@permission_required('data_builder:detail') +def data_builder_detail(): + ret, err_msg = DataBuilderController(request.args).builder_detail() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +@api.route('/data/builder/create', methods=['POST']) +@login_required +@permission_required('data_builder:create') +def data_builder_create(): + create_id, err_msg = DataBuilderController(request.get_json() or {}).builder_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + + +@api.route('/data/builder/update', methods=['POST']) +@login_required +@permission_required('data_builder:update') +def data_builder_update(): + update_id, err_msg = DataBuilderController(request.get_json() or {}).builder_update() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': update_id}) + + +@api.route('/data/builder/delete', methods=['POST']) +@login_required +@permission_required('data_builder:delete') +def data_builder_delete(): + delete_id, err_msg = DataBuilderController(request.get_json() or {}).builder_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + + +@api.route('/data/builder/execute', methods=['POST']) +@login_required +@permission_required('data_builder:execute') +def data_builder_execute(): + ret, err_msg = DataBuilderController(request.get_json() or {}).builder_execute() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +@api.route('/data/task/status', methods=['GET']) +@login_required +@permission_required('data_task:status') +def data_task_status(): + ret, err_msg = DataBuilderController(request.args).task_status() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + + +@api.route('/role/list', methods=['GET']) +@login_required +@permission_required('role:list') +def role_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.role_list()) + finally: + controller.close_session() + + +@api.route('/role/page/list', methods=['GET']) +@login_required +@permission_required('role:list') +def role_page_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.role_page_list()) + finally: + controller.close_session() + + +@api.route('/role/detail', methods=['GET']) +@login_required +@permission_required('role:detail') +def role_detail(): + controller = RbacController(request.args) + try: + ret, err_msg = controller.role_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('/role/create', methods=['POST']) +@login_required +@permission_required('role:create') +def role_create(): + controller = RbacController(request.get_json() or {}) + try: + create_id, err_msg = controller.role_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/role/update', methods=['POST']) +@login_required +@permission_required('role:update') +def role_update(): + controller = RbacController(request.get_json() or {}) + try: + update_id, err_msg = controller.role_update() + 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('/role/delete', methods=['POST']) +@login_required +@permission_required('role:delete') +def role_delete(): + controller = RbacController(request.get_json() or {}) + try: + delete_id, err_msg = controller.role_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/permission/list', methods=['GET']) +@login_required +@permission_required('permission:list') +def permission_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.permission_list()) + finally: + controller.close_session() + + +@api.route('/permission/detail', methods=['GET']) +@login_required +@permission_required('permission:detail') +def permission_detail(): + controller = RbacController(request.args) + try: + ret, err_msg = controller.permission_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('/permission/create', methods=['POST']) +@login_required +@permission_required('permission:create') +def permission_create(): + controller = RbacController(request.get_json() or {}) + try: + create_id, err_msg = controller.permission_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/permission/update', methods=['POST']) +@login_required +@permission_required('permission:update') +def permission_update(): + controller = RbacController(request.get_json() or {}) + try: + update_id, err_msg = controller.permission_update() + 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('/permission/delete', methods=['POST']) +@login_required +@permission_required('permission:delete') +def permission_delete(): + controller = RbacController(request.get_json() or {}) + try: + delete_id, err_msg = controller.permission_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/menu/tree', methods=['GET']) +@login_required +@permission_required('menu:list') +def menu_tree(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.menu_tree()) + finally: + controller.close_session() + + +@api.route('/menu/current/list', methods=['GET']) +@login_required +def current_menu_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.current_menu_list()) + finally: + controller.close_session() + + +@api.route('/role/menu/tree', methods=['GET']) +@login_required +@permission_required('role_menu:list') +def role_menu_tree(): + controller = RbacController(request.args) + try: + ret, err_msg = controller.role_menu_tree() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + finally: + controller.close_session() + + +@api.route('/menu/detail', methods=['GET']) +@login_required +@permission_required('menu:detail') +def menu_detail(): + controller = RbacController(request.args) + try: + ret, err_msg = controller.menu_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('/menu/create', methods=['POST']) +@login_required +@permission_required('menu:create') +def menu_create(): + controller = RbacController(request.get_json() or {}) + try: + create_id, err_msg = controller.menu_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/menu/update', methods=['POST']) +@login_required +@permission_required('menu:update') +def menu_update(): + controller = RbacController(request.get_json() or {}) + try: + update_id, err_msg = controller.menu_update() + 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('/menu/delete', methods=['POST']) +@login_required +@permission_required('menu:delete') +def menu_delete(): + controller = RbacController(request.get_json() or {}) + try: + delete_id, err_msg = controller.menu_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/role/permission/list', methods=['GET']) +@login_required +@permission_required('role_permission:list') +def role_permission_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.role_permission_list()) + finally: + controller.close_session() + + +@api.route('/role/permission/assign', methods=['POST']) +@login_required +@permission_required('role_permission:assign') +def role_permission_assign(): + controller = RbacController(request.get_json() or {}) + try: + role_id, err_msg = controller.role_permission_assign() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': role_id}) + finally: + controller.close_session() + + +@api.route('/role/menu/list', methods=['GET']) +@login_required +@permission_required('role_menu:list') +def role_menu_list(): + controller = RbacController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.role_menu_list()) + finally: + controller.close_session() + + +@api.route('/role/menu/assign', methods=['POST']) +@login_required +@permission_required('role_menu:assign') +def role_menu_assign(): + controller = RbacController(request.get_json() or {}) + try: + role_id, err_msg = controller.role_menu_assign() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': role_id}) + finally: + controller.close_session() + + +@api.route('/user/list', methods=['GET']) +@login_required +@permission_required('user:list') +def user_list(): + controller = UserController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.user_list()) + finally: + controller.close_session() + + +@api.route('/user/detail', methods=['GET']) +@login_required +@permission_required('user:detail') +def user_detail(): + controller = UserController(request.args) + try: + ret, err_msg = controller.user_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('/user/create', methods=['POST']) +@login_required +@permission_required('user:create') +def user_create(): + controller = UserController(request.get_json() or {}) + try: + create_id, err_msg = controller.user_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/user/update', methods=['POST']) +@login_required +@permission_required('user:update') +def user_update(): + controller = UserController(request.get_json() or {}) + try: + update_id, err_msg = controller.user_update() + 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('/user/delete', methods=['POST']) +@login_required +@permission_required('user:delete') +def user_delete(): + controller = UserController(request.get_json() or {}) + try: + delete_id, err_msg = controller.user_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': delete_id}) + finally: + controller.close_session() + + +@api.route('/user/role/list', methods=['GET']) +@login_required +@permission_required('user_role:list') +def user_role_list(): + controller = UserController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.user_role_list()) + finally: + controller.close_session() + + +@api.route('/user/role/assign', methods=['POST']) +@login_required +@permission_required('user_role:assign') +def user_role_assign(): + controller = UserController(request.get_json() or {}) + try: + user_id, err_msg = controller.user_role_assign() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': user_id}) + finally: + controller.close_session() + + +@api.route('/auth/register', methods=['POST']) +def auth_register(): + controller = UserController(request.get_json() or {}) + try: + create_id, err_msg = controller.register() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': create_id}) + finally: + controller.close_session() + + +@api.route('/auth/login', methods=['POST']) +def auth_login(): + controller = UserController(request.get_json() or {}) + try: + ret, err_msg = controller.login() + if err_msg: + return ApiResponse.build_failure(40011, msg=err_msg) + return ApiResponse.build_success(20000, data=ret) + except OperationalError: + return ApiResponse.build_failure(40011, msg='数据库连接失败,请稍后重试!') + finally: + controller.close_session() + + +@api.route('/bug/list', methods=['GET']) +@login_required +@permission_required('bug:list') +def bug_list(): + controller = BugController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.bug_list()) + finally: + controller.close_session() + + +@api.route('/bug/detail', methods=['GET']) +@login_required +@permission_required('bug:detail') +def bug_detail(): + controller = BugController(request.args) + try: + ret, err_msg = controller.bug_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('/bug/create', methods=['POST']) +@login_required +@permission_required('bug:create') +def bug_create(): + controller = BugController(request.get_json() or {}) + try: + bug_id, err_msg = controller.bug_create() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': bug_id}) + finally: + controller.close_session() + + +@api.route('/bug/update', methods=['POST']) +@login_required +@permission_required('bug:update') +def bug_update(): + controller = BugController(request.get_json() or {}) + try: + bug_id, err_msg = controller.bug_update() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': bug_id}) + finally: + controller.close_session() + + +@api.route('/bug/delete', methods=['POST']) +@login_required +@permission_required('bug:delete') +def bug_delete(): + controller = BugController(request.get_json() or {}) + try: + bug_id, err_msg = controller.bug_delete() + if err_msg: + return ApiResponse.build_failure(40012, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': bug_id}) + finally: + controller.close_session() + + +@api.route('/bug/history/add', methods=['POST']) +@login_required +@permission_required('bug:update') +def bug_history_add(): + controller = BugController(request.get_json() or {}) + try: + success, err_msg = controller.bug_history_add() + if err_msg: + return ApiResponse.build_failure(40015, msg=err_msg) + return ApiResponse.build_success(20000, data={'success': success}) + finally: + controller.close_session() + + +@api.route('/bug/comment/add', methods=['POST']) +@login_required +@permission_required('bug:comment') +def bug_comment_add(): + controller = BugController(request.get_json() or {}) + try: + comment_id, err_msg = controller.bug_comment_add() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'id': comment_id}) + finally: + controller.close_session() + + +@api.route('/bug/stats', methods=['GET']) +@login_required +@permission_required('bug:stats') +def bug_stats(): + controller = BugController(request.args) + try: + return ApiResponse.build_success(20000, data=controller.bug_stats()) + finally: + controller.close_session() + + +@api.route('/bug/upload', methods=['POST']) +@login_required +@permission_required('bug:create') +def bug_upload(): + controller = BugUploadController(request) + try: + file_url, err_msg = controller.bug_upload() + if err_msg: + return ApiResponse.build_failure(40009, msg=err_msg) + return ApiResponse.build_success(20000, data={'url': file_url}) + finally: + controller.close_session() + diff --git a/attachment/用例导入模版.xlsx b/attachment/用例导入模版.xlsx new file mode 100644 index 0000000..45f306d Binary files /dev/null and b/attachment/用例导入模版.xlsx differ diff --git a/bug_api_document.md b/bug_api_document.md new file mode 100644 index 0000000..de189b7 --- /dev/null +++ b/bug_api_document.md @@ -0,0 +1,539 @@ +# Bug 管理系统接口文档 + +## 一、接口清单 + +| 接口路径 | 方法 | 权限 | 说明 | +| ------------------ | ---- | ------------- | ---------- | +| `/bug/list` | GET | `bug:list` | Bug 列表(分页) | +| `/bug/detail` | GET | `bug:detail` | Bug 详情 | +| `/bug/create` | POST | `bug:create` | 创建 Bug | +| `/bug/update` | POST | `bug:update` | 更新 Bug | +| `/bug/delete` | POST | `bug:delete` | 删除 Bug | +| `/bug/comment/add` | POST | `bug:comment` | 添加评论 | +| `/bug/stats` | GET | `bug:stats` | Bug 统计 | +| `/bug/upload` | POST | `bug:create` | 图片上传 | + +*** + +## 二、接口详细说明 + +### 1. Bug 列表 + +**GET /bug/list** + +查询 Bug 列表,支持多维度筛选。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ------------------------- | ------ | -- | ---------- | +| productId / product\_id | Number | 否 | 产品 ID | +| projectId / project\_id | Number | 否 | 项目 ID | +| moduleId / module\_id | Number | 否 | 模块 ID | +| bugType / bug\_type | Number | 否 | Bug 类型 | +| severity | Number | 否 | 严重程度 | +| priority | Number | 否 | 优先级 | +| status | Number | 否 | 状态 | +| assigneeId / assignee\_id | Number | 否 | 负责人 ID | +| keyword | String | 否 | 关键词(标题/描述) | +| pageNo / page | Number | 否 | 页码,默认 1 | +| pageSize / size | Number | 否 | 每页数量,默认 20 | + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "bug_key": "BUG-001", + "title": "登录页面无法加载", + "description": "点击登录按钮后页面无响应", + "bug_type": 1, + "severity": 1, + "priority": 1, + "status": 2, + "assignee_id": 1, + "reporter_id": 2, + "product_id": 1, + "project_id": 1, + "module_id": 1, + "case_id": 101, + "plan_id": 5, + "environment": "test", + "created_time": "2026-05-06 10:00:00", + "updated_time": "2026-05-06 11:00:00" + } + ], + "total": 1 + } +} +``` + +*** + +### 2. Bug 详情 + +**GET /bug/detail** + +查询 Bug 详细信息,包含评论和历史记录。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ---------- | ------ | -- | ------ | +| bugId / id | Number | 是 | Bug ID | + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "id": 1, + "bug_key": "BUG-001", + "title": "登录页面无法加载", + "description": "点击登录按钮后页面无响应...", + "bug_type": 1, + "severity": 1, + "priority": 1, + "status": 2, + "assignee_id": 1, + "reporter_id": 2, + "product_id": 1, + "project_id": 1, + "module_id": 1, + "case_id": 101, + "plan_id": 5, + "environment": "test", + "steps": "1. 打开登录页面\n2. 输入用户名密码\n3. 点击登录", + "attachments": [], + "created_time": "2026-05-06 10:00:00", + "updated_time": "2026-05-06 11:00:00", + "comments": [ + { + "id": 1, + "bug_id": 1, + "content": "已收到,正在处理", + "user_id": 1, + "created_time": "2026-05-06 10:30:00" + } + ], + "history": [ + { + "id": 1, + "bug_id": 1, + "field_name": "status", + "old_value": "0", + "new_value": "2", + "operator_id": 1, + "created_time": "2026-05-06 10:30:00" + } + ] + } +} +``` + +*** + +### 3. 创建 Bug + +**POST /bug/create** + +创建新的 Bug 报告。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | -- | ----------- | +| title | String | 是 | Bug 标题 | +| description | String | 否 | Bug 描述 | +| bugType / bug\_type | Number | 否 | Bug 类型,默认 1 | +| severity | Number | 否 | 严重程度,默认 2 | +| priority | Number | 否 | 优先级,默认 2 | +| productId / product\_id | Number | 是 | 产品 ID | +| projectId / project\_id | Number | 是 | 项目 ID | +| moduleId / module\_id | Number | 否 | 模块 ID | +| caseId / case\_id | Number | 否 | 关联测试用例 ID | +| planId / plan\_id | Number | 否 | 关联测试计划 ID | +| environment | String | 否 | 测试环境 | +| steps | String | 否 | 复现步骤 | + +#### 请求体示例 + +```json +{ + "title": "登录页面无法加载", + "description": "点击登录按钮后页面无响应", + "bugType": 1, + "severity": 1, + "priority": 1, + "productId": 1, + "projectId": 1, + "moduleId": 1, + "environment": "test", + "steps": "1. 打开登录页面\n2. 输入用户名密码\n3. 点击登录" +} +``` + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "id": 1 + } +} +``` + +*** + +### 4. 更新 Bug + +**POST /bug/update** + +更新 Bug 信息。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ------------------------- | ------ | -- | --------- | +| bugId / id | Number | 是 | Bug ID | +| title | String | 否 | Bug 标题 | +| description | String | 否 | Bug 描述 | +| bugType / bug\_type | Number | 否 | Bug 类型 | +| severity | Number | 否 | 严重程度 | +| priority | Number | 否 | 优先级 | +| status | Number | 否 | 状态 | +| assigneeId / assignee\_id | Number | 否 | 负责人 ID | +| moduleId / module\_id | Number | 否 | 模块 ID | +| caseId / case\_id | Number | 否 | 关联测试用例 ID | +| planId / plan\_id | Number | 否 | 关联测试计划 ID | +| environment | String | 否 | 测试环境 | +| steps | String | 否 | 复现步骤 | + +#### 请求体示例 + +```json +{ + "bugId": 1, + "status": 3, + "assigneeId": 1 +} +``` + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "id": 1 + } +} +``` + +*** + +### 5. 删除 Bug + +**POST /bug/delete** + +软删除 Bug。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ---------- | ------ | -- | ------ | +| bugId / id | Number | 是 | Bug ID | + +#### 请求体示例 + +```json +{ + "bugId": 1 +} +``` + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "id": 1 + } +} +``` + +*** + +### 6. 添加评论 + +**POST /bug/comment/add** + +为 Bug 添加评论。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ---------- | ------ | -- | ------ | +| bugId / id | Number | 是 | Bug ID | +| content | String | 是 | 评论内容 | + +#### 请求体示例 + +```json +{ + "bugId": 1, + "content": "已收到,正在处理" +} +``` + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "id": 1 + } +} +``` + +*** + +### 7. Bug 统计 + +**GET /bug/stats** + +获取 Bug 统计信息。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +| ----------------------- | ------ | -- | ----- | +| productId / product\_id | Number | 否 | 产品 ID | +| projectId / project\_id | Number | 否 | 项目 ID | + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "total": 100, + "new": 10, + "pending": 20, + "in_progress": 30, + "resolved": 25, + "closed": 10, + "rejected": 5, + "by_severity": { + "critical": 15, + "major": 30, + "medium": 40, + "minor": 15 + }, + "by_priority": { + "high": 40, + "medium": 45, + "low": 15 + }, + "by_type": { + "functional": 40, + "ui": 25, + "performance": 15, + "security": 10, + "compatibility": 10 + } + } +} +``` + +*** + +### 8. 图片上传 + +**POST /bug/upload** + +上传 Bug 相关图片,返回图片访问 URL。 + +#### 请求参数 + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| file | File | 是 | 图片文件 | + +#### 支持的文件格式 + +- png +- jpg / jpeg +- gif +- bmp + +#### 调用示例(curl) + +```bash +curl -X POST "http://39.170.26.156:8888/it/api/bug/upload" \ + -H "accessToken: your_token" \ + -F "file=@screenshot.png" +``` + +#### 响应示例 + +```json +{ + "code": 20000, + "message": "success", + "data": { + "url": "http://39.170.26.156:8888/uploads/bug/bug-20260506100000-abc12345.png" + } +} +``` + +#### 错误响应 + +```json +{ + "code": 40009, + "message": "未找到上传文件" +} +``` + +```json +{ + "code": 40009, + "message": "不支持的文件格式,仅支持:png, jpg, jpeg, gif, bmp" +} +``` + +*** + +## 三、枚举值说明 + +### Bug 类型 (bug\_type) + +| 值 | 名称 | 说明 | +| - | ----- | ---------- | +| 1 | 功能缺陷 | 核心功能不能正常工作 | +| 2 | UI 问题 | 界面显示、交互问题 | +| 3 | 性能问题 | 响应慢、资源占用高 | +| 4 | 安全漏洞 | 安全相关问题 | +| 5 | 兼容性问题 | 浏览器/平台兼容问题 | + +### 严重程度 (severity) + +| 值 | 名称 | 说明 | +| - | -- | --------- | +| 1 | 致命 | 系统崩溃、数据丢失 | +| 2 | 严重 | 核心功能不可用 | +| 3 | 中等 | 功能受限但可用 | +| 4 | 轻微 | 小问题,不影响使用 | + +### 优先级 (priority) + +| 值 | 名称 | 说明 | +| - | -- | ------ | +| 1 | 高 | 需要立即处理 | +| 2 | 中 | 按计划处理 | +| 3 | 低 | 空闲时处理 | + +### 状态 (status) + +| 值 | 名称 | 说明 | +| - | --- | ------------- | +| 0 | 新建 | Bug 刚创建,待审核 | +| 1 | 待处理 | 已确认,等待分配 | +| 2 | 进行中 | 已分配,正在修复 | +| 3 | 已解决 | 修复完成,待验证 | +| 4 | 已关闭 | 验证通过,已关闭 | +| 5 | 已拒绝 | 非 Bug 或重复,已拒绝 | + +*** + +## 四、状态流转规则 + +``` + ┌─────────────────┐ + │ 0-新建 │ + └────────┬────────┘ + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + ┌─────────────┐ ┌─────────────┐ + │ 1-待处理 │ │ 5-已拒绝 │ + └──────┬──────┘ └─────────────┘ + │ + ▼ + ┌─────────────┐ + │ 2-进行中 │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 3-已解决 │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ 4-已关闭 │ + └─────────────┘ +``` + +**转换规则:** + +- 新建 → 待处理 / 已拒绝 +- 待处理 → 进行中 / 已拒绝 +- 进行中 → 已解决 / 待处理 +- 已解决 → 已关闭 / 待处理 +- 已关闭 → 待处理(重新打开) +- 已拒绝 → 待处理(重新打开) + +*** + +## 五、通用状态码 + +| 状态码 | 说明 | +| ----- | ------------ | +| 20000 | 成功 | +| 40004 | 未登录或缺少 token | +| 40009 | 参数错误或新增失败 | +| 40011 | 未查询到对应记录 | +| 40012 | 更新或删除失败 | +| 40013 | 权限不足 | + +*** + +## 六、认证方式 + +所有接口(除登录/注册外)需在请求头携带 `accessToken`: + +```bash +curl -H "accessToken: your_token" http://localhost:8081/it/api/bug/list +``` + +*** + +## 七、权限配置 + +| 权限编码 | 权限名称 | +| ----------- | --------- | +| bug:list | 查看 Bug 列表 | +| bug:detail | 查看 Bug 详情 | +| bug:create | 创建 Bug | +| bug:update | 更新 Bug | +| bug:delete | 删除 Bug | +| bug:comment | 添加评论 | +| bug:stats | 查看统计 | + diff --git a/common/__pycache__/__init__.cpython-38.pyc b/common/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..2acdf60 Binary files /dev/null and b/common/__pycache__/__init__.cpython-38.pyc differ diff --git a/common/__pycache__/apiResponse.cpython-38.pyc b/common/__pycache__/apiResponse.cpython-38.pyc new file mode 100644 index 0000000..32b4d99 Binary files /dev/null and b/common/__pycache__/apiResponse.cpython-38.pyc differ diff --git a/common/__pycache__/cronRequest.cpython-38.pyc b/common/__pycache__/cronRequest.cpython-38.pyc new file mode 100644 index 0000000..971452d Binary files /dev/null and b/common/__pycache__/cronRequest.cpython-38.pyc differ diff --git a/common/__pycache__/dataBuilderExecutor.cpython-38.pyc b/common/__pycache__/dataBuilderExecutor.cpython-38.pyc new file mode 100644 index 0000000..57f276c Binary files /dev/null and b/common/__pycache__/dataBuilderExecutor.cpython-38.pyc differ diff --git a/common/__pycache__/getRequest.cpython-38.pyc b/common/__pycache__/getRequest.cpython-38.pyc new file mode 100644 index 0000000..a1c0de6 Binary files /dev/null and b/common/__pycache__/getRequest.cpython-38.pyc differ diff --git a/common/__pycache__/getUserInfo.cpython-38.pyc b/common/__pycache__/getUserInfo.cpython-38.pyc new file mode 100644 index 0000000..3382b06 Binary files /dev/null and b/common/__pycache__/getUserInfo.cpython-38.pyc differ diff --git a/common/__pycache__/sqlSession.cpython-38.pyc b/common/__pycache__/sqlSession.cpython-38.pyc new file mode 100644 index 0000000..d2a95ab Binary files /dev/null and b/common/__pycache__/sqlSession.cpython-38.pyc differ diff --git a/common/dataBuilderExecutor.py b/common/dataBuilderExecutor.py new file mode 100644 index 0000000..bb10f97 --- /dev/null +++ b/common/dataBuilderExecutor.py @@ -0,0 +1,57 @@ +# encoding: UTF-8 +import random +import re +import string + + +class DataBuilderExecutor(object): + """造数器同步执行器。 + + 当前版本只做安全的模板渲染和内置随机函数,不执行用户脚本,避免引入任意代码执行风险。 + """ + + def __init__(self, builder_def, env=None): + # builder_def 对应 data_builder.definition 字段,约定为 JSON 对象。 + self.builder_def = builder_def or {} + # 保留 steps 字段,后续扩展 http/db 流程编排时继续复用。 + self.steps = self.builder_def.get('steps', []) + self.env = env or {} + # context 是模板变量来源,支持 {{env.xxx}} 和 {{param.xxx}}。 + self.context = {'env': self.env} + self.results = [] + + def execute(self, params=None): + """执行造数器定义并返回渲染后的 output。""" + params = params or {} + self.context['param'] = params + output = self.builder_def.get('output') or {} + # 如果未配置 output,返回基础执行信息,方便前端判断定义是否为空。 + return self._render_template(output) if output else {'params': params, 'steps': len(self.steps)} + + def _render_template(self, obj): + """递归渲染字符串、字典、数组中的 {{变量}}。""" + if isinstance(obj, str): + return re.sub(r'\{\{([^}]+)\}\}', lambda m: str(self._get_value(m.group(1).strip())), obj) + if isinstance(obj, dict): + return {k: self._render_template(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self._render_template(item) for item in obj] + return obj + + def _get_value(self, expr): + """获取模板表达式的值,支持内置随机函数和点路径取值。""" + if expr.startswith('random_string(') and expr.endswith(')'): + length = int(expr[len('random_string('):-1] or 8) + return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length)) + if expr == 'random_phone()': + return '1{}{}'.format(random.choice(['3', '5', '7', '8', '9']), ''.join(random.choice(string.digits) for _ in range(9))) + current = self.context + # 点路径示例:param.amount、env.base_url。 + for part in expr.split('.'): + if isinstance(current, dict): + current = current.get(part) + else: + current = getattr(current, part, None) + if current is None: + return '' + return current diff --git a/common/sqlSession.py b/common/sqlSession.py index 1fd2b20..3895627 100644 --- a/common/sqlSession.py +++ b/common/sqlSession.py @@ -8,6 +8,7 @@ from const import sparkatp_sql_uri from logger import logger _ENGINE_CACHE = {} +_SESSION_FACTORY_CACHE = {} """ @@ -32,27 +33,30 @@ class SqlSession: return f"postgresql+psycopg2://{user}:{urlquote(str(password))}@{host}:{port}/{database}" def get_session(self): - engine = _ENGINE_CACHE.get(self.sql_uri) - if engine is None: - engine = create_engine( - self.sql_uri, - pool_size=5, - max_overflow=10, - pool_pre_ping=True, - pool_recycle=1800, - pool_timeout=30, - connect_args={ - 'connect_timeout': 20, - 'options': '-c timezone=Asia/Shanghai' - } - ) - _ENGINE_CACHE[self.sql_uri] = engine - Session = sessionmaker(bind=engine) - session = Session() - return session + session_factory = _SESSION_FACTORY_CACHE.get(self.sql_uri) + if session_factory is None: + engine = _ENGINE_CACHE.get(self.sql_uri) + if engine is None: + engine = create_engine( + self.sql_uri, + pool_size=20, + max_overflow=30, + pool_pre_ping=True, + pool_recycle=1200, + pool_timeout=60, + pool_use_lifo=True, + connect_args={ + 'connect_timeout': 10, + 'options': '-c timezone=Asia/Shanghai' + } + ) + _ENGINE_CACHE[self.sql_uri] = engine + session_factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + _SESSION_FACTORY_CACHE[self.sql_uri] = session_factory + return session_factory() - def query(self, obj): - return self._session.query(obj) + def query(self, *args): + return self._session.query(*args) def add(self, added): self._session.add(added) @@ -69,6 +73,9 @@ class SqlSession: def commit(self): self._session.commit() + def rollback(self): + self._session.rollback() + def close(self): self._session.close() diff --git a/const.py b/const.py index 1277fb3..09baac8 100644 --- a/const.py +++ b/const.py @@ -29,18 +29,18 @@ RES_CODE = { 40013: 'scene_id不能为空!' } -sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("dffa3866-dac8-49b1-a59e-725302bdfa4a")}@172.18.0.1:18366/postgres' +sparkatp_sql_uri = f'postgresql+psycopg2://postgres:{urlquote("difyai123456")}@39.170.26.156:8366/test' EXECUTE_DB_CONFIG = { 'ZHYY': { 'st': { - 'host': '172.18.0.1', + 'host': '124.220.32.45', 'port': 18666, 'user': 'postgres', 'password': '89c75b17-1738-4b7d-b651-4c65a5a662ab', 'database': 'smart_management_st' }, 'dev': { - 'host': '172.18.0.1', + 'host': '124.220.32.45', 'port': 18566, 'user': 'postgres', 'password': 'f267abd8-7005-472f-8cef-c1738c691c6c', @@ -56,7 +56,7 @@ EXECUTE_DB_CONFIG = { }, 'DLZ': { 'st': { - 'host': '172.18.0.1', + 'host': '124.220.32.45', 'port': 18666, 'user': 'joyhub', 'password': 'e364be29-6089-4610-97d5-0037a28d0703', @@ -82,4 +82,4 @@ STRESS_URI = 'https://qe.bg.huohua.cn' QE_DOMAIN = 'https://qe.bg.huohua.cn' PASSWORD = quote('AcUVeRb8lN') -REDIS_URL = "redis://:{}@redis.qa.cn:6379/30".format(PASSWORD) +REDIS_URL = 'redis://124.220.32.45:7379/15' diff --git a/manage.py b/manage.py index f9f4e25..7b2e9f8 100644 --- a/manage.py +++ b/manage.py @@ -1,11 +1,26 @@ # encoding: UTF-8 from flask_cors import CORS -from flask import make_response, jsonify, request, redirect +from flask import make_response, jsonify, request, redirect, send_from_directory, safe_join from app import create_app +import os app = create_app() CORS(app, resources=r'/*') +UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'attachment', 'bug_picture') +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER + +@app.route('/uploads/') +def uploaded_file(filename): + try: + safe_path = safe_join(app.config['UPLOAD_FOLDER'], filename) + if not os.path.exists(safe_path): + return f"File not found: {filename}", 404 + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + except Exception as e: + return f"Error: {str(e)}", 500 + if __name__ == '__main__': app.run(host='0.0.0.0', port=5010, debug=True) diff --git a/数据库语句 b/数据库语句 new file mode 100644 index 0000000..0502364 --- /dev/null +++ b/数据库语句 @@ -0,0 +1,534 @@ +-- ========================================================= +-- 测试管理模块数据库初始化脚本(_time 字段版本) +-- 适用数据库:PostgreSQL +-- 说明: +-- 1. 本脚本与当前后端代码字段保持一致 +-- 2. 所有时间字段统一使用 *_time 后缀 +-- 3. 主表包含 is_delete 逻辑删除字段 +-- ========================================================= + + +-- ========================================================= +-- 一、项目相关 +-- ========================================================= + +-- ------------------------- +-- 1. 项目表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS project ( + id BIGSERIAL PRIMARY KEY, + key VARCHAR(32) UNIQUE NOT NULL, + name VARCHAR(128) NOT NULL, + description TEXT, + department VARCHAR(64), + status SMALLINT DEFAULT 1, + config JSONB DEFAULT '{}'::jsonb, + created_by BIGINT, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE project IS '项目表'; +COMMENT ON COLUMN project.id IS '主键ID'; +COMMENT ON COLUMN project.key IS '项目唯一标识,如 XP2024'; +COMMENT ON COLUMN project.name IS '项目名称'; +COMMENT ON COLUMN project.description IS '项目描述'; +COMMENT ON COLUMN project.department IS '所属部门'; +COMMENT ON COLUMN project.status IS '项目状态:1启用 0禁用'; +COMMENT ON COLUMN project.config IS '扩展配置,JSON格式'; +COMMENT ON COLUMN project.created_by IS '创建人'; +COMMENT ON COLUMN project.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN project.created_time IS '创建时间'; +COMMENT ON COLUMN project.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_project_status ON project(status); +CREATE INDEX IF NOT EXISTS idx_project_is_delete ON project(is_delete); + + +-- ------------------------- +-- 2. 项目成员表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS project_member ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL, + role SMALLINT NOT NULL, + joined_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE project_member IS '项目成员表'; +COMMENT ON COLUMN project_member.id IS '主键ID'; +COMMENT ON COLUMN project_member.project_id IS '项目ID'; +COMMENT ON COLUMN project_member.user_id IS '用户ID'; +COMMENT ON COLUMN project_member.role IS '角色:1测试经理 2测试工程师 3开发工程师 4访客'; +COMMENT ON COLUMN project_member.joined_time IS '加入时间'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_project_member ON project_member(project_id, user_id); +CREATE INDEX IF NOT EXISTS idx_member_user ON project_member(user_id); +CREATE INDEX IF NOT EXISTS idx_member_project_id ON project_member(project_id); + + +-- ------------------------- +-- 3. 环境配置表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS environment ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + name VARCHAR(64) NOT NULL, + variables JSONB NOT NULL, + is_encrypted BOOLEAN DEFAULT FALSE, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE environment IS '环境配置表'; +COMMENT ON COLUMN environment.id IS '主键ID'; +COMMENT ON COLUMN environment.project_id IS '项目ID'; +COMMENT ON COLUMN environment.name IS '环境名称,如 dev/test/staging/prod'; +COMMENT ON COLUMN environment.variables IS '环境变量配置,JSON格式'; +COMMENT ON COLUMN environment.is_encrypted IS '敏感信息是否已加密'; +COMMENT ON COLUMN environment.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN environment.created_time IS '创建时间'; + +CREATE INDEX IF NOT EXISTS idx_environment_project_id ON environment(project_id); +CREATE INDEX IF NOT EXISTS idx_environment_is_delete ON environment(is_delete); + + +-- ------------------------- +-- 4. 产品表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS product ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(128) NOT NULL, + code VARCHAR(64) UNIQUE NOT NULL, + description TEXT, + status SMALLINT DEFAULT 1, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE product IS '产品表'; +COMMENT ON COLUMN product.id IS '主键ID'; +COMMENT ON COLUMN product.name IS '产品名称'; +COMMENT ON COLUMN product.code IS '产品编码'; +COMMENT ON COLUMN product.description IS '产品描述'; +COMMENT ON COLUMN product.status IS '产品状态:1启用 0禁用'; +COMMENT ON COLUMN product.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN product.created_time IS '创建时间'; +COMMENT ON COLUMN product.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_product_status ON product(status); +CREATE INDEX IF NOT EXISTS idx_product_is_delete ON product(is_delete); + + +-- ========================================================= +-- 二、用例相关 +-- ========================================================= + +-- ------------------------- +-- 4. 模块表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS module ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + parent_id BIGINT DEFAULT 0, + name VARCHAR(128) NOT NULL, + sort_order INTEGER DEFAULT 0, + path VARCHAR(512), + is_delete INTEGER DEFAULT 0 +); + +COMMENT ON TABLE module IS '模块树表,支持多层级模块结构'; +COMMENT ON COLUMN module.id IS '主键ID'; +COMMENT ON COLUMN module.project_id IS '项目ID'; +COMMENT ON COLUMN module.parent_id IS '父模块ID,0表示根节点'; +COMMENT ON COLUMN module.name IS '模块名称'; +COMMENT ON COLUMN module.sort_order IS '排序值'; +COMMENT ON COLUMN module.path IS '模块路径,如 /1/23/45'; +COMMENT ON COLUMN module.is_delete IS '逻辑删除标记:0未删除 1已删除'; + +CREATE INDEX IF NOT EXISTS idx_module_project ON module(project_id); +CREATE INDEX IF NOT EXISTS idx_module_parent_id ON module(parent_id); +CREATE INDEX IF NOT EXISTS idx_module_is_delete ON module(is_delete); + + +-- ------------------------- +-- 5. 用例表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS test_case ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + module_id BIGINT REFERENCES module(id) ON DELETE SET NULL, + case_key VARCHAR(64) NOT NULL, + title VARCHAR(255) NOT NULL, + preconditions TEXT, + steps JSONB NOT NULL DEFAULT '[]'::jsonb, + priority SMALLINT DEFAULT 2, + case_type SMALLINT DEFAULT 1, + tags VARCHAR(64)[] DEFAULT '{}'::varchar[], + status SMALLINT DEFAULT 1, + created_by BIGINT, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE test_case IS '测试用例表'; +COMMENT ON COLUMN test_case.id IS '主键ID'; +COMMENT ON COLUMN test_case.project_id IS '项目ID'; +COMMENT ON COLUMN test_case.module_id IS '所属模块ID'; +COMMENT ON COLUMN test_case.case_key IS '项目内唯一编号,如 TC-001'; +COMMENT ON COLUMN test_case.title IS '用例标题'; +COMMENT ON COLUMN test_case.preconditions IS '前置条件'; +COMMENT ON COLUMN test_case.steps IS '测试步骤,JSON数组'; +COMMENT ON COLUMN test_case.priority IS '优先级:0P0 1P1 2P2 3P3'; +COMMENT ON COLUMN test_case.case_type IS '用例类型:1功能 2性能 3安全 4接口'; +COMMENT ON COLUMN test_case.tags IS '标签数组'; +COMMENT ON COLUMN test_case.status IS '状态:1正常 2已废弃 3评审中'; +COMMENT ON COLUMN test_case.created_by IS '创建人'; +COMMENT ON COLUMN test_case.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN test_case.created_time IS '创建时间'; +COMMENT ON COLUMN test_case.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_case_project ON test_case(project_id); +CREATE INDEX IF NOT EXISTS idx_case_module ON test_case(module_id); +CREATE INDEX IF NOT EXISTS idx_case_priority ON test_case(priority); +CREATE INDEX IF NOT EXISTS idx_case_status ON test_case(status); +CREATE INDEX IF NOT EXISTS idx_case_is_delete ON test_case(is_delete); +CREATE UNIQUE INDEX IF NOT EXISTS uk_test_case_project_case_key ON test_case(project_id, case_key); + + +-- ------------------------- +-- 6. 用例快照表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS case_snapshot ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL REFERENCES test_case(id) ON DELETE CASCADE, + version INTEGER NOT NULL, + snapshot JSONB NOT NULL, + created_by BIGINT, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE case_snapshot IS '用例版本快照表'; +COMMENT ON COLUMN case_snapshot.id IS '主键ID'; +COMMENT ON COLUMN case_snapshot.case_id IS '用例ID'; +COMMENT ON COLUMN case_snapshot.version IS '版本号'; +COMMENT ON COLUMN case_snapshot.snapshot IS '完整快照内容'; +COMMENT ON COLUMN case_snapshot.created_by IS '创建人'; +COMMENT ON COLUMN case_snapshot.created_time IS '创建时间'; + +CREATE INDEX IF NOT EXISTS idx_case_snapshot_case_id ON case_snapshot(case_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_case_snapshot_case_version ON case_snapshot(case_id, version); + + +-- ------------------------- +-- 7. 用例评审表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS case_review ( + id BIGSERIAL PRIMARY KEY, + case_id BIGINT NOT NULL REFERENCES test_case(id) ON DELETE CASCADE, + reviewer_id BIGINT NOT NULL, + status SMALLINT DEFAULT 0, + comments TEXT, + diff_content TEXT, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + reviewed_time TIMESTAMP +); + +COMMENT ON TABLE case_review IS '用例评审表'; +COMMENT ON COLUMN case_review.id IS '主键ID'; +COMMENT ON COLUMN case_review.case_id IS '用例ID'; +COMMENT ON COLUMN case_review.reviewer_id IS '评审人ID'; +COMMENT ON COLUMN case_review.status IS '评审状态:0待评审 1通过 2驳回 3建议修改'; +COMMENT ON COLUMN case_review.comments IS '评审意见'; +COMMENT ON COLUMN case_review.diff_content IS '变更差异内容,通常为JSON diff字符串'; +COMMENT ON COLUMN case_review.created_time IS '创建时间'; +COMMENT ON COLUMN case_review.reviewed_time IS '评审时间'; + +CREATE INDEX IF NOT EXISTS idx_case_review_case_id ON case_review(case_id); +CREATE INDEX IF NOT EXISTS idx_case_review_reviewer_id ON case_review(reviewer_id); + + +-- ========================================================= +-- 三、测试计划相关 +-- ========================================================= + +-- ------------------------- +-- 8. 测试计划表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS test_plan ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + version VARCHAR(32), + description TEXT, + start_date DATE, + end_date DATE, + owner_id BIGINT, + status SMALLINT DEFAULT 0, + environment_id BIGINT REFERENCES environment(id), + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE test_plan IS '测试计划表'; +COMMENT ON COLUMN test_plan.id IS '主键ID'; +COMMENT ON COLUMN test_plan.project_id IS '项目ID'; +COMMENT ON COLUMN test_plan.name IS '计划名称'; +COMMENT ON COLUMN test_plan.version IS '测试版本号'; +COMMENT ON COLUMN test_plan.description IS '计划描述'; +COMMENT ON COLUMN test_plan.start_date IS '开始日期'; +COMMENT ON COLUMN test_plan.end_date IS '结束日期'; +COMMENT ON COLUMN test_plan.owner_id IS '负责人ID'; +COMMENT ON COLUMN test_plan.status IS '计划状态:0草稿 1进行中 2已完成 3已归档'; +COMMENT ON COLUMN test_plan.environment_id IS '关联环境ID'; +COMMENT ON COLUMN test_plan.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN test_plan.created_time IS '创建时间'; +COMMENT ON COLUMN test_plan.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_test_plan_project_id ON test_plan(project_id); +CREATE INDEX IF NOT EXISTS idx_test_plan_status ON test_plan(status); +CREATE INDEX IF NOT EXISTS idx_test_plan_is_delete ON test_plan(is_delete); + + +-- ------------------------- +-- 9. 计划用例表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS plan_case ( + id BIGSERIAL PRIMARY KEY, + plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, + case_id BIGINT NOT NULL REFERENCES test_case(id), + assignee_id BIGINT, + round_no INTEGER DEFAULT 1, + status SMALLINT DEFAULT 0, + actual_result TEXT, + defect_links JSONB DEFAULT '[]'::jsonb, + attachments JSONB DEFAULT '[]'::jsonb, + executed_time TIMESTAMP, + execution_duration INTEGER +); + +COMMENT ON TABLE plan_case IS '计划与用例关联表,同时存储执行结果'; +COMMENT ON COLUMN plan_case.id IS '主键ID'; +COMMENT ON COLUMN plan_case.plan_id IS '计划ID'; +COMMENT ON COLUMN plan_case.case_id IS '用例ID'; +COMMENT ON COLUMN plan_case.assignee_id IS '执行人ID'; +COMMENT ON COLUMN plan_case.round_no IS '执行轮次'; +COMMENT ON COLUMN plan_case.status IS '执行状态:0未开始 1通过 2失败 3阻塞'; +COMMENT ON COLUMN plan_case.actual_result IS '实际执行结果'; +COMMENT ON COLUMN plan_case.defect_links IS '缺陷链接数组'; +COMMENT ON COLUMN plan_case.attachments IS '附件数组'; +COMMENT ON COLUMN plan_case.executed_time IS '执行时间'; +COMMENT ON COLUMN plan_case.execution_duration IS '执行耗时,单位秒'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_plan_case_round ON plan_case(plan_id, case_id, round_no); +CREATE INDEX IF NOT EXISTS idx_plan_case_plan ON plan_case(plan_id); +CREATE INDEX IF NOT EXISTS idx_plan_case_assignee ON plan_case(assignee_id); +CREATE INDEX IF NOT EXISTS idx_plan_case_status ON plan_case(status); + + +-- ------------------------- +-- 10. 测试轮次表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS test_round ( + id BIGSERIAL PRIMARY KEY, + plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, + round_no INTEGER NOT NULL, + name VARCHAR(64), + start_date DATE, + end_date DATE +); + +COMMENT ON TABLE test_round IS '测试轮次表'; +COMMENT ON COLUMN test_round.id IS '主键ID'; +COMMENT ON COLUMN test_round.plan_id IS '计划ID'; +COMMENT ON COLUMN test_round.round_no IS '轮次编号'; +COMMENT ON COLUMN test_round.name IS '轮次名称'; +COMMENT ON COLUMN test_round.start_date IS '开始日期'; +COMMENT ON COLUMN test_round.end_date IS '结束日期'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_test_round_plan_round_no ON test_round(plan_id, round_no); +CREATE INDEX IF NOT EXISTS idx_test_round_plan_id ON test_round(plan_id); + + +-- ========================================================= +-- 四、报告相关 +-- ========================================================= + +-- ------------------------- +-- 11. 报告表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS report ( + id BIGSERIAL PRIMARY KEY, + plan_id BIGINT NOT NULL REFERENCES test_plan(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + report_type SMALLINT DEFAULT 1, + summary JSONB, + content TEXT, + file_url VARCHAR(512), + generated_by BIGINT, + generated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE report IS '测试报告表'; +COMMENT ON COLUMN report.id IS '主键ID'; +COMMENT ON COLUMN report.plan_id IS '计划ID'; +COMMENT ON COLUMN report.name IS '报告名称'; +COMMENT ON COLUMN report.report_type IS '报告类型:1实时报告 2归档报告'; +COMMENT ON COLUMN report.summary IS '报告统计摘要,JSON格式'; +COMMENT ON COLUMN report.content IS '报告HTML内容'; +COMMENT ON COLUMN report.file_url IS '导出文件地址'; +COMMENT ON COLUMN report.generated_by IS '生成人'; +COMMENT ON COLUMN report.generated_time IS '生成时间'; + +CREATE INDEX IF NOT EXISTS idx_report_plan_id ON report(plan_id); +CREATE INDEX IF NOT EXISTS idx_report_generated_time ON report(generated_time); + + +-- ------------------------- +-- 12. 缺陷同步表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS defect_sync ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id), + external_id VARCHAR(64) NOT NULL, + external_system VARCHAR(32), + plan_case_id BIGINT REFERENCES plan_case(id), + status VARCHAR(32), + last_sync_time TIMESTAMP +); + +COMMENT ON TABLE defect_sync IS '缺陷同步表,用于记录外部缺陷系统关联关系'; +COMMENT ON COLUMN defect_sync.id IS '主键ID'; +COMMENT ON COLUMN defect_sync.project_id IS '项目ID'; +COMMENT ON COLUMN defect_sync.external_id IS '外部缺陷ID,如 JIRA-123'; +COMMENT ON COLUMN defect_sync.external_system IS '外部系统,如 jira/tapd/zentao'; +COMMENT ON COLUMN defect_sync.plan_case_id IS '计划用例执行ID'; +COMMENT ON COLUMN defect_sync.status IS '外部缺陷状态'; +COMMENT ON COLUMN defect_sync.last_sync_time IS '最后同步时间'; + +CREATE INDEX IF NOT EXISTS idx_defect_sync_project_id ON defect_sync(project_id); +CREATE INDEX IF NOT EXISTS idx_defect_sync_plan_case_id ON defect_sync(plan_case_id); +CREATE INDEX IF NOT EXISTS idx_defect_sync_external_id ON defect_sync(external_id); + + +-- ========================================================= +-- 五、造数相关 +-- ========================================================= + +-- ------------------------- +-- 13. 造数器表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS data_builder ( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL REFERENCES project(id) ON DELETE CASCADE, + name VARCHAR(128) NOT NULL, + description TEXT, + builder_type SMALLINT DEFAULT 1, + definition JSONB NOT NULL, + input_schema JSONB, + output_example JSONB, + created_by BIGINT, + is_delete INTEGER DEFAULT 0, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +COMMENT ON TABLE data_builder IS '数据构造器表'; +COMMENT ON COLUMN data_builder.id IS '主键ID'; +COMMENT ON COLUMN data_builder.project_id IS '项目ID'; +COMMENT ON COLUMN data_builder.name IS '造数器名称'; +COMMENT ON COLUMN data_builder.description IS '造数器描述'; +COMMENT ON COLUMN data_builder.builder_type IS '造数器类型:1流程编排 2SQL 3脚本'; +COMMENT ON COLUMN data_builder.definition IS '造数逻辑定义,JSON格式'; +COMMENT ON COLUMN data_builder.input_schema IS '输入参数结构定义'; +COMMENT ON COLUMN data_builder.output_example IS '输出示例'; +COMMENT ON COLUMN data_builder.created_by IS '创建人'; +COMMENT ON COLUMN data_builder.is_delete IS '逻辑删除标记:0未删除 1已删除'; +COMMENT ON COLUMN data_builder.created_time IS '创建时间'; +COMMENT ON COLUMN data_builder.updated_time IS '更新时间'; + +CREATE INDEX IF NOT EXISTS idx_data_builder_project_id ON data_builder(project_id); +CREATE INDEX IF NOT EXISTS idx_data_builder_is_delete ON data_builder(is_delete); + + +-- ------------------------- +-- 14. 造数任务表 +-- ------------------------- +CREATE TABLE IF NOT EXISTS data_task ( + id BIGSERIAL PRIMARY KEY, + builder_id BIGINT NOT NULL REFERENCES data_builder(id), + project_id BIGINT NOT NULL, + params JSONB, + status SMALLINT DEFAULT 0, + result_data JSONB, + error_message TEXT, + created_by BIGINT, + created_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + completed_time TIMESTAMP +); + +COMMENT ON TABLE data_task IS '数据生成任务表'; +COMMENT ON COLUMN data_task.id IS '主键ID'; +COMMENT ON COLUMN data_task.builder_id IS '造数器ID'; +COMMENT ON COLUMN data_task.project_id IS '项目ID'; +COMMENT ON COLUMN data_task.params IS '任务入参,JSON格式'; +COMMENT ON COLUMN data_task.status IS '任务状态:0等待 1执行中 2成功 3失败'; +COMMENT ON COLUMN data_task.result_data IS '生成结果数据'; +COMMENT ON COLUMN data_task.error_message IS '错误信息'; +COMMENT ON COLUMN data_task.created_by IS '创建人'; +COMMENT ON COLUMN data_task.created_time IS '创建时间'; +COMMENT ON COLUMN data_task.completed_time IS '完成时间'; + +CREATE INDEX IF NOT EXISTS idx_task_status ON data_task(status); +CREATE INDEX IF NOT EXISTS idx_data_task_builder_id ON data_task(builder_id); +CREATE INDEX IF NOT EXISTS idx_data_task_project_id ON data_task(project_id); + + +-- ========================================================= +-- 六、更新时间自动维护触发器 +-- 说明: +-- PostgreSQL 需要借助触发器维护 updated_time +-- ========================================================= + +CREATE OR REPLACE FUNCTION update_updated_time_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_time = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_project_updated_time ON project; +CREATE TRIGGER trg_project_updated_time +BEFORE UPDATE ON project +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); + +DROP TRIGGER IF EXISTS trg_product_updated_time ON product; +CREATE TRIGGER trg_product_updated_time +BEFORE UPDATE ON product +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); + +DROP TRIGGER IF EXISTS trg_test_case_updated_time ON test_case; +CREATE TRIGGER trg_test_case_updated_time +BEFORE UPDATE ON test_case +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); + +DROP TRIGGER IF EXISTS trg_test_plan_updated_time ON test_plan; +CREATE TRIGGER trg_test_plan_updated_time +BEFORE UPDATE ON test_plan +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); + +DROP TRIGGER IF EXISTS trg_data_builder_updated_time ON data_builder; +CREATE TRIGGER trg_data_builder_updated_time +BEFORE UPDATE ON data_builder +FOR EACH ROW +EXECUTE FUNCTION update_updated_time_column(); \ No newline at end of file