feat: restrict permission roles

This commit is contained in:
Frudrax Cheng
2026-06-06 13:50:56 +08:00
parent a55f515930
commit 5edb25ac4e
17 changed files with 229 additions and 175 deletions
+13 -11
View File
@@ -96,21 +96,23 @@ backend-go/
- **Employee Permission Codes**: `POST /api/employee-serials/generate`, `POST /api/employee-serials/:serialNumber/qrcode`, `GET /api/employee-serials/:serialNumber/query`, `GET /api/employee-serials`, `PATCH /api/employee-serials/:serialNumber`, `PUT /api/employee-serials/:serialNumber`, `POST /api/employee-serials/:serialNumber/revoke`, `DELETE /api/employee-serials/:serialNumber` - **Employee Permission Codes**: `POST /api/employee-serials/generate`, `POST /api/employee-serials/:serialNumber/qrcode`, `GET /api/employee-serials/:serialNumber/query`, `GET /api/employee-serials`, `PATCH /api/employee-serials/:serialNumber`, `PUT /api/employee-serials/:serialNumber`, `POST /api/employee-serials/:serialNumber/revoke`, `DELETE /api/employee-serials/:serialNumber`
- **Product Traces**: `GET /api/product-traces/:serialNumber/query`, `POST /api/product-traces`, `GET /api/product-traces`, `GET /api/product-traces/:serialNumber`, `PATCH /api/product-traces/:serialNumber`, `POST /api/product-traces/:serialNumber/qrcode`, `POST /api/product-traces/:serialNumber/wechat-qrcode`, `POST /api/product-traces/:serialNumber/revoke`, `DELETE /api/product-traces/:serialNumber` - **Product Traces**: `GET /api/product-traces/:serialNumber/query`, `POST /api/product-traces`, `GET /api/product-traces`, `GET /api/product-traces/:serialNumber`, `PATCH /api/product-traces/:serialNumber`, `POST /api/product-traces/:serialNumber/qrcode`, `POST /api/product-traces/:serialNumber/wechat-qrcode`, `POST /api/product-traces/:serialNumber/revoke`, `DELETE /api/product-traces/:serialNumber`
- **Aftersales** (公开): `GET /api/aftersales/:serialNumber/query`, `POST /api/aftersales/:serialNumber/confirm` - **Aftersales** (公开): `GET /api/aftersales/:serialNumber/query`, `POST /api/aftersales/:serialNumber/confirm`
- **Aftersales** (技术员+管理员): `POST /api/aftersales`, `GET /api/aftersales`, `GET /api/aftersales/:serialNumber`, `PATCH /api/aftersales/:serialNumber`, `POST /api/aftersales/:serialNumber/qrcode`, `POST /api/aftersales/:serialNumber/submit` - **Aftersales** (工单角色+管理员): `GET /api/aftersales`, `GET /api/aftersales/:serialNumber`, `PATCH /api/aftersales/:serialNumber`, `POST /api/aftersales/:serialNumber/qrcode`, `POST /api/aftersales/:serialNumber/submit`
- **Aftersales** (仅管理员): `POST /api/aftersales/:serialNumber/reassign`, `POST /api/aftersales/:serialNumber/force-close`, `DELETE /api/aftersales/:serialNumber` - **Aftersales** (仅管理员): `POST /api/aftersales`, `POST /api/aftersales/:serialNumber/reassign`, `POST /api/aftersales/:serialNumber/force-close`, `DELETE /api/aftersales/:serialNumber`
- **Project Orders** (公开): `GET /api/project-orders/:serialNumber/query`, `POST /api/project-orders/:serialNumber/site-images`, `POST /api/project-orders/:serialNumber/complete` - **Project Orders** (公开): `GET /api/project-orders/:serialNumber/query`, `POST /api/project-orders/:serialNumber/site-images`, `POST /api/project-orders/:serialNumber/complete`
- **Project Orders** (技术员+管理员): `POST /api/project-orders`, `GET /api/project-orders`, `GET /api/project-orders/:serialNumber`, `PATCH /api/project-orders/:serialNumber`, `POST /api/project-orders/:serialNumber/qrcode`, `POST /api/project-orders/:serialNumber/submit` - **Project Orders** (工单角色+管理员): `GET /api/project-orders`, `GET /api/project-orders/:serialNumber`, `PATCH /api/project-orders/:serialNumber`, `POST /api/project-orders/:serialNumber/qrcode`, `POST /api/project-orders/:serialNumber/submit`
- **Project Orders** (仅管理员): `POST /api/project-orders/:serialNumber/reassign`, `POST /api/project-orders/:serialNumber/force-close`, `DELETE /api/project-orders/:serialNumber` - **Project Orders** (仅管理员): `POST /api/project-orders`, `POST /api/project-orders/:serialNumber/reassign`, `POST /api/project-orders/:serialNumber/force-close`, `DELETE /api/project-orders/:serialNumber`
- **Users** (技术员+管理员): `GET /api/users/assignable` - **Users** (管理员): `GET /api/users/assignable`
- **Employees** (仅管理员): `POST /api/employees`, `GET /api/employees`, `PATCH /api/employees/:id`, `POST /api/employees/:id/reset-password`, `DELETE /api/employees/:id` - **Employees** (仅管理员): `POST /api/employees`, `GET /api/employees`, `PATCH /api/employees/:id`, `POST /api/employees/:id/reset-password`, `DELETE /api/employees/:id`
### Roles and permissions ### Roles and permissions
- Roles are limited to `admin`, `technician`, and `employee`. - `admin` is the system administrator role and has full backend access.
- `admin` has full backend access. - Permission management may create/edit only four equal work-order roles: `software_engineer`, `hardware_engineer`, `business_manager`, `project_manager`.
- `technician` only has aftersales/work-order module access. - The four work-order roles can log in only to process aftersales/project orders assigned to themselves.
- `employee` has no backend login access and does not require a password. - The four work-order roles must not access dashboard, permission management, product traces, assignable user lists, creation/deletion/reassign/force-close endpoints, or other admin-only resources.
- `technician` is legacy-compatible and may keep work-order access, but must not be offered as a new role.
- `employee` is legacy/no backend login access and must not be offered as a new role.
- Creating an employee through `/api/employees` creates employee master data and automatically generates one employee permission code bound by `employeeId`. - Creating an employee through `/api/employees` creates employee master data and automatically generates one employee permission code bound by `employeeId`.
- `admin` / `technician` creation requires an initial password; `employee` creation must not require one. - Creating managed work-order roles requires an initial password; creating `admin` or `employee` through permission management is forbidden.
### Business Boundaries ### Business Boundaries
- Enterprise/company-code management was removed. Do not reintroduce `/api/companies`, `/api/serials`, `CompaniesService`, `SerialsService`, or a company-management UI. - Enterprise/company-code management was removed. Do not reintroduce `/api/companies`, `/api/serials`, `CompaniesService`, `SerialsService`, or a company-management UI.
@@ -248,7 +250,7 @@ After modifying Swagger annotations, run `make swagger`.
### Middleware ### Middleware
- **JWTAuthMiddleware**: Validates JWT tokens, sets user in context - **JWTAuthMiddleware**: Validates JWT tokens, sets user in context
- **AdminMiddleware**: Checks if user has admin role - **AdminMiddleware**: Checks if user has admin role
- **TechnicianMiddleware**: Allows admin and technician roles (used for aftersales endpoints) - **TechnicianMiddleware**: Allows admin, the four managed work-order roles, and legacy `technician` (used for work-order processing endpoints)
- Access current user: `user, ok := GetCurrentUser(ctx)` - Access current user: `user, ok := GetCurrentUser(ctx)`
### Git Hooks ### Git Hooks
+25 -21
View File
@@ -46,7 +46,7 @@ backend-go/
├── logger/ # 日志管理 ├── logger/ # 日志管理
│ └── logger.go # 结构化日志(使用 Zap) │ └── logger.go # 结构化日志(使用 Zap)
├── middleware/ # 中间件层 ├── middleware/ # 中间件层
│ └── auth.go # JWT 认证、管理员/技术员权限检查 │ └── auth.go # JWT 认证、管理员/工单角色权限检查
├── models/ # 数据模型和 DTO ├── models/ # 数据模型和 DTO
│ └── models.go # User、Company、EmployeeSerial、ProductTrace、AftersalesOrder、ProjectOrder 等模型定义 │ └── models.go # User、Company、EmployeeSerial、ProductTrace、AftersalesOrder、ProjectOrder 等模型定义
├── routes/ # 路由配置 ├── routes/ # 路由配置
@@ -298,12 +298,12 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
| GET | `/api/project-orders/:serialNumber/query` | 公开查询项目工单 | 否 | 任何 | | GET | `/api/project-orders/:serialNumber/query` | 公开查询项目工单 | 否 | 任何 |
| POST | `/api/project-orders/:serialNumber/site-images` | 上传现场图片 | 否 | 任何 | | POST | `/api/project-orders/:serialNumber/site-images` | 上传现场图片 | 否 | 任何 |
| POST | `/api/project-orders/:serialNumber/complete` | 工程师提交完成 | 否 | 任何 | | POST | `/api/project-orders/:serialNumber/complete` | 工程师提交完成 | 否 | 任何 |
| POST | `/api/project-orders` | 创建项目工单 | 是 | 管理员/技术员 | | POST | `/api/project-orders` | 创建项目工单 | 是 | 管理员 |
| GET | `/api/project-orders` | 项目工单列表 | 是 | 管理员/技术员 | | GET | `/api/project-orders` | 项目工单列表 | 是 | 管理员/工单角色 |
| GET | `/api/project-orders/:serialNumber` | 项目工单详情 | 是 | 管理员/技术员 | | GET | `/api/project-orders/:serialNumber` | 项目工单详情 | 是 | 管理员/工单角色 |
| PATCH | `/api/project-orders/:serialNumber` | 更新项目工单 | 是 | 管理员/技术员 | | PATCH | `/api/project-orders/:serialNumber` | 更新项目工单 | 是 | 管理员/工单角色 |
| POST | `/api/project-orders/:serialNumber/qrcode` | 生成项目工单二维码 | 是 | 管理员/技术员 | | POST | `/api/project-orders/:serialNumber/qrcode` | 生成项目工单二维码 | 是 | 管理员/工单角色 |
| POST | `/api/project-orders/:serialNumber/submit` | 后台提交项目完成 | 是 | 管理员/技术员 | | POST | `/api/project-orders/:serialNumber/submit` | 后台提交项目完成 | 是 | 管理员/工单角色 |
| POST | `/api/project-orders/:serialNumber/reassign` | 工单分配 | 是 | 管理员 | | POST | `/api/project-orders/:serialNumber/reassign` | 工单分配 | 是 | 管理员 |
| POST | `/api/project-orders/:serialNumber/force-close` | 强制完成 | 是 | 管理员 | | POST | `/api/project-orders/:serialNumber/force-close` | 强制完成 | 是 | 管理员 |
| DELETE | `/api/project-orders/:serialNumber` | 删除项目工单 | 是 | 管理员 | | DELETE | `/api/project-orders/:serialNumber` | 删除项目工单 | 是 | 管理员 |
@@ -313,6 +313,7 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
- 工单号格式: `zjbf-xm-YYMMDDNN` - 工单号格式: `zjbf-xm-YYMMDDNN`
- 现场图片最多 18 张 - 现场图片最多 18 张
- 仅需要工程师签名,无客户签字环节 - 仅需要工程师签名,无客户签字环节
- 软件工程师、硬件工程师、商务经理、项目经理只能处理管理员分配给自己的项目工单
- 完成状态使用 `已完成` - 完成状态使用 `已完成`
### 售后工单 ### 售后工单
@@ -321,13 +322,13 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
| ------ | --------------------------------------------- | -------------------------- | -------- | --------------- | | ------ | --------------------------------------------- | -------------------------- | -------- | --------------- |
| GET | `/api/aftersales/:serialNumber/query` | 公开查询工单(脱敏) | 否 | 任何 | | GET | `/api/aftersales/:serialNumber/query` | 公开查询工单(脱敏) | 否 | 任何 |
| POST | `/api/aftersales/:serialNumber/confirm` | 客户授权/未授权确认 | 否 | 任何 | | POST | `/api/aftersales/:serialNumber/confirm` | 客户授权/未授权确认 | 否 | 任何 |
| POST | `/api/aftersales` | 创建售后工单 | 是 | 管理员/技术员 | | POST | `/api/aftersales` | 创建售后工单 | 是 | 管理员 |
| GET | `/api/aftersales` | 工单列表(支持筛选) | 是 | 管理员/技术员 | | GET | `/api/aftersales` | 工单列表(支持筛选) | 是 | 管理员/工单角色 |
| GET | `/api/aftersales/:serialNumber` | 工单详情 | 是 | 管理员/技术员 | | GET | `/api/aftersales/:serialNumber` | 工单详情 | 是 | 管理员/工单角色 |
| PATCH | `/api/aftersales/:serialNumber` | 更新工单(仅负责人或管理员)| 是 | 管理员/技术员 | | PATCH | `/api/aftersales/:serialNumber` | 更新工单(仅负责人或管理员)| 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/qrcode` | 生成工单二维码 | 是 | 管理员/技术员 | | POST | `/api/aftersales/:serialNumber/qrcode` | 生成工单二维码 | 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/submit` | 提交客户确认 | 是 | 管理员/技术员 | | POST | `/api/aftersales/:serialNumber/submit` | 提交客户确认 | 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/reassign` | 工单分配(重新分配技术员 | 是 | 管理员 | | POST | `/api/aftersales/:serialNumber/reassign` | 工单分配(重新分配工单负责人) | 是 | 管理员 |
| POST | `/api/aftersales/:serialNumber/force-close` | 强制关闭工单 | 是 | 管理员 | | POST | `/api/aftersales/:serialNumber/force-close` | 强制关闭工单 | 是 | 管理员 |
| DELETE | `/api/aftersales/:serialNumber` | 删除工单 | 是 | 管理员 | | DELETE | `/api/aftersales/:serialNumber` | 删除工单 | 是 | 管理员 |
@@ -336,6 +337,7 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
- 工单号格式: `zjbf-sh-YYMMDDNN`(年份后 2 位 + 月份 2 位 + 日期 2 位 + 当天序号至少 2 位,例:`zjbf-sh-26052801` - 工单号格式: `zjbf-sh-YYMMDDNN`(年份后 2 位 + 月份 2 位 + 日期 2 位 + 当天序号至少 2 位,例:`zjbf-sh-26052801`
- 序号按天重置,软删除工单不释放编号(避免回收造成混淆) - 序号按天重置,软删除工单不释放编号(避免回收造成混淆)
- 工单里的企业名称是售后客户信息,只保存在工单中 - 工单里的企业名称是售后客户信息,只保存在工单中
- 软件工程师、硬件工程师、商务经理、项目经理只能处理管理员分配给自己的售后工单
- 二维码扫码后客户在网页签名(canvas)后点「已授权」确认;选择「未授权」需填写退回原因 - 二维码扫码后客户在网页签名(canvas)后点「已授权」确认;选择「未授权」需填写退回原因
- 签名以 PNG dataURL 形式持久化到工单(`signature` 字段),管理员详情页可查看留底 - 签名以 PNG dataURL 形式持久化到工单(`signature` 字段),管理员详情页可查看留底
- 签名校验:必须为 `data:image/png;base64,``data:image/jpeg;base64,` 前缀,解码后 200B500KB - 签名校验:必须为 `data:image/png;base64,``data:image/jpeg;base64,` 前缀,解码后 200B500KB
@@ -347,22 +349,24 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
| 方法 | 路径 | 描述 | 需要认证 | 角色 | | 方法 | 路径 | 描述 | 需要认证 | 角色 |
| ------ | ----------------------------------- | -------------------------- | -------- | ------------- | | ------ | ----------------------------------- | -------------------------- | -------- | ------------- |
| GET | `/api/users/assignable` | 可分配用户列表(用于售后) | 是 | 管理员/技术员 | | GET | `/api/users/assignable` | 可分配工单负责人列表 | 是 | 管理员 |
| POST | `/api/employees` | 创建员工并自动生成员工码 | 是 | 管理员 | | POST | `/api/employees` | 创建员工并自动生成员工码 | 是 | 管理员 |
| GET | `/api/employees` | 员工列表(分页+筛选) | 是 | 管理员 | | GET | `/api/employees` | 员工列表(分页+筛选) | 是 | 管理员 |
| PATCH | `/api/employees/:id` | 更新员工姓名/电话/工号/岗位/角色 | 是 | 管理员 | | PATCH | `/api/employees/:id` | 更新员工姓名/电话/工号/岗位/角色 | 是 | 管理员 |
| POST | `/api/employees/:id/reset-password` | 重置后台账号密码 | 是 | 管理员 | | POST | `/api/employees/:id/reset-password` | 重置后台账号密码 | 是 | 管理员 |
| DELETE | `/api/employees/:id` | 删除员工 | 是 | 管理员 | | DELETE | `/api/employees/:id` | 删除员工 | 是 | 管理员 |
**员工角色**: **角色规则**:
- `admin`:管理员,拥有全部后台权限,包括权限管理、产品溯源、工单分配、强制关闭和删除工单 - `admin`系统管理员,拥有全部后台权限,包括权限管理、产品溯源、创建工单、工单分配、强制关闭和删除工单
- `technician`:技术员,仅拥有工单模块权限,可创建/处理工单,可使用 `assignable` 查询可分配同事 - 权限管理只能创建/编辑四个对等工单角色:`software_engineer`(软件工程师)、`hardware_engineer`(硬件工程师)、`business_manager`(商务经理)、`project_manager`(项目经理)
- `employee`:员工,无后台登录权限,不需要密码,仅用于员工主档和员工码查询 - 四个工单角色只能登录后台处理管理员分配给自己的项目工单和售后工单,不能访问控制台、权限管理、产品溯源等其他模块
- `technician` 是旧数据兼容角色,不再作为新建选项
- `employee` 是旧普通员工角色,无后台登录权限,不再作为新建选项
**保护规则**: **保护规则**:
- 不能删除自己;不能将自己的 admin 角色降级;不能删除最后一个 admin - 不能删除自己;不能将自己的 admin 角色降级;不能删除最后一个 admin
- 创建 `admin` / `technician` 必须设置初始密码,密码 bcrypt 加密存储 - 创建四个工单角色必须设置初始密码,密码 bcrypt 加密存储
- 创建 `employee` 不要求密码,且不能登录后台 - 不允许通过权限管理创建 `admin``employee`
## 测试 ## 测试
+7 -7
View File
@@ -59,7 +59,7 @@ func (c *AftersalesController) Create(ctx *gin.Context) {
// FindAll 获取售后工单列表 // FindAll 获取售后工单列表
// @Summary 获取售后工单列表 // @Summary 获取售后工单列表
// @Description 支持分页、搜索、按状态/服务类型/技术员筛选 // @Description 支持分页、搜索、按状态/服务类型/工单负责人筛选
// @Tags 售后工单 // @Tags 售后工单
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -68,7 +68,7 @@ func (c *AftersalesController) Create(ctx *gin.Context) {
// @Param search query string false "搜索关键词" // @Param search query string false "搜索关键词"
// @Param workOrderStatus query string false "工单状态" // @Param workOrderStatus query string false "工单状态"
// @Param serviceType query string false "服务类型" // @Param serviceType query string false "服务类型"
// @Param technicianId query int false "技术员 ID" // @Param technicianId query int false "工单负责人 ID"
// @Param mine query bool false "仅查看自己负责的工单" // @Param mine query bool false "仅查看自己负责的工单"
// @Success 200 {object} models.PaginationResponse // @Success 200 {object} models.PaginationResponse
// @Failure 401 {object} models.ErrorResponse // @Failure 401 {object} models.ErrorResponse
@@ -179,9 +179,9 @@ func (c *AftersalesController) Update(ctx *gin.Context) {
}) })
} }
// SubmitForConfirmation 技术员提交客户确认 // SubmitForConfirmation 工单负责人提交客户确认
// @Summary 提交客户确认 // @Summary 提交客户确认
// @Description 技术员填写处理结果后提交,工单进入"待客户确认"状态 // @Description 工单负责人填写处理结果后提交,工单进入"待客户确认"状态
// @Tags 售后工单 // @Tags 售后工单
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -257,14 +257,14 @@ func (c *AftersalesController) GenerateQRCode(ctx *gin.Context) {
}) })
} }
// Reassign 重新分配技术员(仅管理员) // Reassign 重新分配工单负责人(仅管理员)
// @Summary 重新分配技术员 // @Summary 重新分配工单负责人
// @Tags 售后工单 // @Tags 售后工单
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param serialNumber path string true "工单号" // @Param serialNumber path string true "工单号"
// @Param data body models.ReassignAftersalesDTO true "新技术员 ID" // @Param data body models.ReassignAftersalesDTO true "新工单负责人 ID"
// @Success 200 {object} models.DataResponse // @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse // @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse // @Failure 401 {object} models.ErrorResponse
+2 -2
View File
@@ -43,8 +43,8 @@ func (c *AuthController) Login(ctx *gin.Context) {
ErrorResponse(ctx, http.StatusUnauthorized, err.Error()) ErrorResponse(ctx, http.StatusUnauthorized, err.Error())
return return
} }
if user.Role == "employee" { if !models.HasBackendAccess(user.Role) {
ErrorResponse(ctx, http.StatusForbidden, "员工账号无后台登录权限") ErrorResponse(ctx, http.StatusForbidden, "账号无后台登录权限")
return return
} }
+7 -7
View File
@@ -59,7 +59,7 @@ func (c *ProjectOrdersController) Create(ctx *gin.Context) {
// FindAll 获取项目工单列表 // FindAll 获取项目工单列表
// @Summary 获取项目工单列表 // @Summary 获取项目工单列表
// @Description 支持分页、搜索、按状态/服务类型/技术员筛选 // @Description 支持分页、搜索、按状态/服务类型/工单负责人筛选
// @Tags 项目工单 // @Tags 项目工单
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
@@ -68,7 +68,7 @@ func (c *ProjectOrdersController) Create(ctx *gin.Context) {
// @Param search query string false "搜索关键词" // @Param search query string false "搜索关键词"
// @Param workOrderStatus query string false "工单状态" // @Param workOrderStatus query string false "工单状态"
// @Param serviceType query string false "服务类型" // @Param serviceType query string false "服务类型"
// @Param technicianId query int false "技术员 ID" // @Param technicianId query int false "工单负责人 ID"
// @Param mine query bool false "仅查看自己负责的工单" // @Param mine query bool false "仅查看自己负责的工单"
// @Success 200 {object} models.PaginationResponse // @Success 200 {object} models.PaginationResponse
// @Failure 401 {object} models.ErrorResponse // @Failure 401 {object} models.ErrorResponse
@@ -179,9 +179,9 @@ func (c *ProjectOrdersController) Update(ctx *gin.Context) {
}) })
} }
// SubmitCompletion 技术员提交完成资料 // SubmitCompletion 工单负责人提交完成资料
// @Summary 提交完成确认 // @Summary 提交完成确认
// @Description 技术员填写处理结果后提交,工单进入"待完成确认"状态 // @Description 工单负责人填写处理结果后提交,工单进入"待完成确认"状态
// @Tags 项目工单 // @Tags 项目工单
// @Accept json // @Accept json
// @Produce json // @Produce json
@@ -257,14 +257,14 @@ func (c *ProjectOrdersController) GenerateQRCode(ctx *gin.Context) {
}) })
} }
// Reassign 重新分配技术员(仅管理员) // Reassign 重新分配工单负责人(仅管理员)
// @Summary 重新分配技术员 // @Summary 重新分配工单负责人
// @Tags 项目工单 // @Tags 项目工单
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param serialNumber path string true "工单号" // @Param serialNumber path string true "工单号"
// @Param data body models.ReassignProjectOrderDTO true "新技术员 ID" // @Param data body models.ReassignProjectOrderDTO true "新工单负责人 ID"
// @Success 200 {object} models.DataResponse // @Success 200 {object} models.DataResponse
// @Failure 400 {object} models.ErrorResponse // @Failure 400 {object} models.ErrorResponse
// @Failure 401 {object} models.ErrorResponse // @Failure 401 {object} models.ErrorResponse
+1 -1
View File
@@ -87,7 +87,7 @@ func (c *UsersController) FindAll(ctx *gin.Context) {
// FindAssignable 获取可分配的用户(admin + technician // FindAssignable 获取可分配的用户(admin + technician
// @Summary 获取可分配用户列表 // @Summary 获取可分配用户列表
// @Description 用于售后工单分配选择技术员/管理员,无需分页 // @Description 用于工单分配选择可派单人员,无需分页
// @Tags 用户管理 // @Tags 用户管理
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
+19 -17
View File
@@ -31,7 +31,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选", "description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -72,7 +72,7 @@ const docTemplate = `{
}, },
{ {
"type": "integer", "type": "integer",
"description": "技术员 ID", "description": "工单负责人 ID",
"name": "technicianId", "name": "technicianId",
"in": "query" "in": "query"
}, },
@@ -519,7 +519,7 @@ const docTemplate = `{
"tags": [ "tags": [
"售后工单" "售后工单"
], ],
"summary": "重新分配技术员", "summary": "重新分配工单负责人",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
@@ -529,7 +529,7 @@ const docTemplate = `{
"required": true "required": true
}, },
{ {
"description": "新技术员 ID", "description": "新工单负责人 ID",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -617,7 +617,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "技术员填写处理结果后提交,工单进入\"待客户确认\"状态", "description": "工单负责人填写处理结果后提交,工单进入\"待客户确认\"状态",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1558,7 +1558,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选", "description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1599,7 +1599,7 @@ const docTemplate = `{
}, },
{ {
"type": "integer", "type": "integer",
"description": "技术员 ID", "description": "工单负责人 ID",
"name": "technicianId", "name": "technicianId",
"in": "query" "in": "query"
}, },
@@ -2046,7 +2046,7 @@ const docTemplate = `{
"tags": [ "tags": [
"项目工单" "项目工单"
], ],
"summary": "重新分配技术员", "summary": "重新分配工单负责人",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
@@ -2056,7 +2056,7 @@ const docTemplate = `{
"required": true "required": true
}, },
{ {
"description": "新技术员 ID", "description": "新工单负责人 ID",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -2144,7 +2144,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "技术员填写处理结果后提交,工单进入\"待完成确认\"状态", "description": "工单负责人填写处理结果后提交,工单进入\"待完成确认\"状态",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -2202,7 +2202,7 @@ const docTemplate = `{
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "用于售后工单分配选择技术员/管理员,无需分页", "description": "用于工单分配选择可派单人员,无需分页",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -2394,9 +2394,10 @@ const docTemplate = `{
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"admin", "software_engineer",
"technician", "hardware_engineer",
"employee" "business_manager",
"project_manager"
] ]
}, },
"username": { "username": {
@@ -2774,9 +2775,10 @@ const docTemplate = `{
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"admin", "software_engineer",
"technician", "hardware_engineer",
"employee" "business_manager",
"project_manager"
] ]
} }
} }
+19 -17
View File
@@ -25,7 +25,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选", "description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -66,7 +66,7 @@
}, },
{ {
"type": "integer", "type": "integer",
"description": "技术员 ID", "description": "工单负责人 ID",
"name": "technicianId", "name": "technicianId",
"in": "query" "in": "query"
}, },
@@ -513,7 +513,7 @@
"tags": [ "tags": [
"售后工单" "售后工单"
], ],
"summary": "重新分配技术员", "summary": "重新分配工单负责人",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
@@ -523,7 +523,7 @@
"required": true "required": true
}, },
{ {
"description": "新技术员 ID", "description": "新工单负责人 ID",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -611,7 +611,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "技术员填写处理结果后提交,工单进入\"待客户确认\"状态", "description": "工单负责人填写处理结果后提交,工单进入\"待客户确认\"状态",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -1552,7 +1552,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选", "description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -1593,7 +1593,7 @@
}, },
{ {
"type": "integer", "type": "integer",
"description": "技术员 ID", "description": "工单负责人 ID",
"name": "technicianId", "name": "technicianId",
"in": "query" "in": "query"
}, },
@@ -2040,7 +2040,7 @@
"tags": [ "tags": [
"项目工单" "项目工单"
], ],
"summary": "重新分配技术员", "summary": "重新分配工单负责人",
"parameters": [ "parameters": [
{ {
"type": "string", "type": "string",
@@ -2050,7 +2050,7 @@
"required": true "required": true
}, },
{ {
"description": "新技术员 ID", "description": "新工单负责人 ID",
"name": "data", "name": "data",
"in": "body", "in": "body",
"required": true, "required": true,
@@ -2138,7 +2138,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "技术员填写处理结果后提交,工单进入\"待完成确认\"状态", "description": "工单负责人填写处理结果后提交,工单进入\"待完成确认\"状态",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@@ -2196,7 +2196,7 @@
"BearerAuth": [] "BearerAuth": []
} }
], ],
"description": "用于售后工单分配选择技术员/管理员,无需分页", "description": "用于工单分配选择可派单人员,无需分页",
"produces": [ "produces": [
"application/json" "application/json"
], ],
@@ -2388,9 +2388,10 @@
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"admin", "software_engineer",
"technician", "hardware_engineer",
"employee" "business_manager",
"project_manager"
] ]
}, },
"username": { "username": {
@@ -2768,9 +2769,10 @@
"role": { "role": {
"type": "string", "type": "string",
"enum": [ "enum": [
"admin", "software_engineer",
"technician", "hardware_engineer",
"employee" "business_manager",
"project_manager"
] ]
} }
} }
+19 -17
View File
@@ -110,9 +110,10 @@ definitions:
type: string type: string
role: role:
enum: enum:
- admin - software_engineer
- technician - hardware_engineer
- employee - business_manager
- project_manager
type: string type: string
username: username:
type: string type: string
@@ -368,9 +369,10 @@ definitions:
type: string type: string
role: role:
enum: enum:
- admin - software_engineer
- technician - hardware_engineer
- employee - business_manager
- project_manager
type: string type: string
type: object type: object
models.User: models.User:
@@ -441,7 +443,7 @@ info:
paths: paths:
/aftersales: /aftersales:
get: get:
description: 支持分页、搜索、按状态/服务类型/技术员筛选 description: 支持分页、搜索、按状态/服务类型/工单负责人筛选
parameters: parameters:
- description: 页码 - description: 页码
in: query in: query
@@ -463,7 +465,7 @@ paths:
in: query in: query
name: serviceType name: serviceType
type: string type: string
- description: 技术员 ID - description: 工单负责人 ID
in: query in: query
name: technicianId name: technicianId
type: integer type: integer
@@ -754,7 +756,7 @@ paths:
name: serialNumber name: serialNumber
required: true required: true
type: string type: string
- description: 技术员 ID - description: 工单负责人 ID
in: body in: body
name: data name: data
required: true required: true
@@ -777,7 +779,7 @@ paths:
$ref: '#/definitions/models.ErrorResponse' $ref: '#/definitions/models.ErrorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 重新分配技术员 summary: 重新分配工单负责人
tags: tags:
- 售后工单 - 售后工单
/aftersales/{serialNumber}/site-images: /aftersales/{serialNumber}/site-images:
@@ -817,7 +819,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 技术员填写处理结果后提交,工单进入"待客户确认"状态 description: 工单负责人填写处理结果后提交,工单进入"待客户确认"状态
parameters: parameters:
- description: 工单号 - description: 工单号
in: path in: path
@@ -1412,7 +1414,7 @@ paths:
- 员工管理 - 员工管理
/project-orders: /project-orders:
get: get:
description: 支持分页、搜索、按状态/服务类型/技术员筛选 description: 支持分页、搜索、按状态/服务类型/工单负责人筛选
parameters: parameters:
- description: 页码 - description: 页码
in: query in: query
@@ -1434,7 +1436,7 @@ paths:
in: query in: query
name: serviceType name: serviceType
type: string type: string
- description: 技术员 ID - description: 工单负责人 ID
in: query in: query
name: technicianId name: technicianId
type: integer type: integer
@@ -1725,7 +1727,7 @@ paths:
name: serialNumber name: serialNumber
required: true required: true
type: string type: string
- description: 技术员 ID - description: 工单负责人 ID
in: body in: body
name: data name: data
required: true required: true
@@ -1748,7 +1750,7 @@ paths:
$ref: '#/definitions/models.ErrorResponse' $ref: '#/definitions/models.ErrorResponse'
security: security:
- BearerAuth: [] - BearerAuth: []
summary: 重新分配技术员 summary: 重新分配工单负责人
tags: tags:
- 项目工单 - 项目工单
/project-orders/{serialNumber}/site-images: /project-orders/{serialNumber}/site-images:
@@ -1788,7 +1790,7 @@ paths:
post: post:
consumes: consumes:
- application/json - application/json
description: 技术员填写处理结果后提交,工单进入"待完成确认"状态 description: 工单负责人填写处理结果后提交,工单进入"待完成确认"状态
parameters: parameters:
- description: 工单号 - description: 工单号
in: path in: path
@@ -1823,7 +1825,7 @@ paths:
- 项目工单 - 项目工单
/users/assignable: /users/assignable:
get: get:
description: 用于售后工单分配选择技术员/管理员,无需分页 description: 用于工单分配选择可派单人员,无需分页
produces: produces:
- application/json - application/json
responses: responses:
+2 -2
View File
@@ -111,7 +111,7 @@ func AdminMiddleware() gin.HandlerFunc {
} }
} }
// TechnicianMiddleware 技术员权限中间件(放行 admin 和 technician // TechnicianMiddleware 工单处理权限中间件(放行 admin 和可派单角色
func TechnicianMiddleware() gin.HandlerFunc { func TechnicianMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
user, exists := c.Get("user") user, exists := c.Get("user")
@@ -123,7 +123,7 @@ func TechnicianMiddleware() gin.HandlerFunc {
} }
userModel := user.(models.User) userModel := user.(models.User)
if userModel.Role != "admin" && userModel.Role != "technician" { if !models.HasWorkOrderAccess(userModel.Role) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"message": "无权限访问此资源", "message": "无权限访问此资源",
}) })
+50 -3
View File
@@ -6,6 +6,53 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
RoleAdmin = "admin"
RoleTechnicianLegacy = "technician"
RoleEmployee = "employee"
RoleSoftwareEngineer = "software_engineer"
RoleHardwareEngineer = "hardware_engineer"
RoleBusinessManager = "business_manager"
RoleProjectManager = "project_manager"
)
// WorkOrderRoles 是权限管理中可创建/编辑的四个对等角色。
var WorkOrderRoles = []string{
RoleSoftwareEngineer,
RoleHardwareEngineer,
RoleBusinessManager,
RoleProjectManager,
}
// AssignableWorkOrderRoles 是可被派单的角色,包含旧 technician 数据兼容。
var AssignableWorkOrderRoles = append([]string{}, append(WorkOrderRoles, RoleTechnicianLegacy)...)
func IsWorkOrderRole(role string) bool {
for _, item := range WorkOrderRoles {
if role == item {
return true
}
}
return false
}
func IsAssignableWorkOrderRole(role string) bool {
for _, item := range AssignableWorkOrderRoles {
if role == item {
return true
}
}
return false
}
func HasBackendAccess(role string) bool {
return role == RoleAdmin || IsAssignableWorkOrderRole(role)
}
func HasWorkOrderAccess(role string) bool {
return role == RoleAdmin || IsAssignableWorkOrderRole(role)
}
// User 模型 // User 模型
type User struct { type User struct {
ID uint `gorm:"primaryKey" json:"id"` ID uint `gorm:"primaryKey" json:"id"`
@@ -94,7 +141,7 @@ type CreateUserDTO struct {
Phone string `json:"phone" validate:"required"` Phone string `json:"phone" validate:"required"`
EmployeeNo string `json:"employeeNo" validate:"required"` EmployeeNo string `json:"employeeNo" validate:"required"`
Position string `json:"position" validate:"required"` Position string `json:"position" validate:"required"`
Role string `json:"role" validate:"required,oneof=admin technician employee"` Role string `json:"role" validate:"required,oneof=software_engineer hardware_engineer business_manager project_manager"`
} }
// UpdateUserDTO 管理员更新用户信息请求 // UpdateUserDTO 管理员更新用户信息请求
@@ -104,7 +151,7 @@ type UpdateUserDTO struct {
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
EmployeeNo string `json:"employeeNo,omitempty"` EmployeeNo string `json:"employeeNo,omitempty"`
Position string `json:"position,omitempty"` Position string `json:"position,omitempty"`
Role string `json:"role,omitempty" validate:"omitempty,oneof=admin technician employee"` Role string `json:"role,omitempty" validate:"omitempty,oneof=software_engineer hardware_engineer business_manager project_manager"`
} }
// AdminResetPasswordDTO 管理员重置用户密码 // AdminResetPasswordDTO 管理员重置用户密码
@@ -295,7 +342,7 @@ type CustomerConfirmDTO struct {
RejectReason string `json:"rejectReason,omitempty" validate:"required_if=Action reject"` RejectReason string `json:"rejectReason,omitempty" validate:"required_if=Action reject"`
} }
// ReassignAftersalesDTO 重新分配技术员请求 // ReassignAftersalesDTO 重新分配工单负责人请求
type ReassignAftersalesDTO struct { type ReassignAftersalesDTO struct {
TechnicianID uint `json:"technicianId" validate:"required"` TechnicianID uint `json:"technicianId" validate:"required"`
} }
+3 -3
View File
@@ -80,7 +80,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
usersRoutes := r.Group("/users") usersRoutes := r.Group("/users")
{ {
usersRoutes.GET("/assignable", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), usersController.FindAssignable) usersRoutes.GET("/assignable", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.FindAssignable)
} }
// 售后工单路由 // 售后工单路由
@@ -93,7 +93,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm) aftersalesRoutes.POST("/:serialNumber/confirm", aftersalesController.CustomerConfirm)
// 技术员 + 管理员 // 技术员 + 管理员
aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Create) aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Create)
aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll) aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll)
aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne) aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne)
aftersalesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Update) aftersalesRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Update)
@@ -116,7 +116,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
projectOrdersRoutes.POST("/:serialNumber/complete", projectOrdersController.EngineerComplete) projectOrdersRoutes.POST("/:serialNumber/complete", projectOrdersController.EngineerComplete)
// 技术员 + 管理员 // 技术员 + 管理员
projectOrdersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Create) projectOrdersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), projectOrdersController.Create)
projectOrdersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindAll) projectOrdersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindAll)
projectOrdersRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindOne) projectOrdersRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindOne)
projectOrdersRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Update) projectOrdersRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Update)
+6 -6
View File
@@ -334,7 +334,7 @@ func (s *AftersalesService) Update(
return &order, nil return &order, nil
} }
// SubmitForConfirmation 技术员提交客户确认 // SubmitForConfirmation 工单负责人提交客户确认
func (s *AftersalesService) SubmitForConfirmation( func (s *AftersalesService) SubmitForConfirmation(
serialNumber string, serialNumber string,
dto models.SubmitForConfirmationDTO, dto models.SubmitForConfirmationDTO,
@@ -539,7 +539,7 @@ func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.Cust
return s.PublicQuery(normalized) return s.PublicQuery(normalized)
} }
// Reassign 重新分配技术员(仅管理员) // Reassign 重新分配工单负责人(仅管理员)
func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) { func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) {
var order models.AftersalesOrder var order models.AftersalesOrder
result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order) result := database.DB.Where("serial_number = ?", normalizeAftersalesSerial(serialNumber)).First(&order)
@@ -553,15 +553,15 @@ func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*m
var technician models.User var technician models.User
if err := database.DB.First(&technician, technicianID).Error; err != nil { if err := database.DB.First(&technician, technicianID).Error; err != nil {
return nil, errors.New("指定的技术员不存在") return nil, errors.New("指定的工单负责人不存在")
} }
if technician.Role != "admin" && technician.Role != "technician" { if !models.IsAssignableWorkOrderRole(technician.Role) {
return nil, errors.New("指定的用户不是技术员或管理员") return nil, errors.New("指定的用户不是可派单人员")
} }
order.TechnicianID = &technicianID order.TechnicianID = &technicianID
if err := database.DB.Save(&order).Error; err != nil { if err := database.DB.Save(&order).Error; err != nil {
return nil, fmt.Errorf("重新分配技术员失败: %w", err) return nil, fmt.Errorf("重新分配工单负责人失败: %w", err)
} }
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
+1 -1
View File
@@ -23,7 +23,7 @@ func (s *AuthService) ValidateUser(username string, password string) (*models.Us
if result.Error != nil { if result.Error != nil {
return nil, errors.New("用户名或密码不正确") return nil, errors.New("用户名或密码不正确")
} }
if user.Role != "admin" && user.Role != "technician" { if !models.HasBackendAccess(user.Role) {
return nil, errors.New("用户名或密码不正确") return nil, errors.New("用户名或密码不正确")
} }
if user.Password == "" { if user.Password == "" {
+6 -6
View File
@@ -213,7 +213,7 @@ func (s *ProjectOrdersService) Update(
return &order, nil return &order, nil
} }
// SubmitCompletion 技术员提交完成资料,进入待完成确认状态 // SubmitCompletion 工单负责人提交完成资料,进入待完成确认状态
func (s *ProjectOrdersService) SubmitCompletion( func (s *ProjectOrdersService) SubmitCompletion(
serialNumber string, serialNumber string,
dto models.SubmitProjectCompletionDTO, dto models.SubmitProjectCompletionDTO,
@@ -395,7 +395,7 @@ func (s *ProjectOrdersService) EngineerComplete(serialNumber string, dto models.
return s.PublicQuery(normalized) return s.PublicQuery(normalized)
} }
// Reassign 重新分配技术员(仅管理员) // Reassign 重新分配工单负责人(仅管理员)
func (s *ProjectOrdersService) Reassign(serialNumber string, technicianID uint) (*models.ProjectOrder, error) { func (s *ProjectOrdersService) Reassign(serialNumber string, technicianID uint) (*models.ProjectOrder, error) {
var order models.ProjectOrder var order models.ProjectOrder
result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order) result := database.DB.Where("serial_number = ?", normalizeProjectOrderSerial(serialNumber)).First(&order)
@@ -409,15 +409,15 @@ func (s *ProjectOrdersService) Reassign(serialNumber string, technicianID uint)
var technician models.User var technician models.User
if err := database.DB.First(&technician, technicianID).Error; err != nil { if err := database.DB.First(&technician, technicianID).Error; err != nil {
return nil, errors.New("指定的技术员不存在") return nil, errors.New("指定的工单负责人不存在")
} }
if technician.Role != "admin" && technician.Role != "technician" { if !models.IsAssignableWorkOrderRole(technician.Role) {
return nil, errors.New("指定的用户不是技术员或管理员") return nil, errors.New("指定的用户不是可派单人员")
} }
order.TechnicianID = &technicianID order.TechnicianID = &technicianID
if err := database.DB.Save(&order).Error; err != nil { if err := database.DB.Save(&order).Error; err != nil {
return nil, fmt.Errorf("重新分配技术员失败: %w", err) return nil, fmt.Errorf("重新分配工单负责人失败: %w", err)
} }
_ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order) _ = database.DB.Preload("Technician").Preload("Creator").Where("serial_number = ?", order.SerialNumber).First(&order)
+20 -12
View File
@@ -31,11 +31,11 @@ func toUserDTO(user models.User) models.UserDTO {
} }
func hasBackendAccess(role string) bool { func hasBackendAccess(role string) bool {
return role == "admin" || role == "technician" return models.HasBackendAccess(role)
} }
func isValidEmployeeRole(role string) bool { func isValidManagedRole(role string) bool {
return role == "admin" || role == "technician" || role == "employee" return models.IsWorkOrderRole(role)
} }
// Create 创建用户 // Create 创建用户
@@ -62,11 +62,11 @@ func (s *UsersService) Create(dto models.CreateUserDTO) (*models.UserDTO, error)
if position == "" { if position == "" {
return nil, errors.New("岗位不能为空") return nil, errors.New("岗位不能为空")
} }
if !isValidEmployeeRole(role) { if !isValidManagedRole(role) {
return nil, errors.New("角色不正确") return nil, errors.New("权限管理只能创建软件工程师、硬件工程师、商务经理、项目经理")
} }
if hasBackendAccess(role) && len(dto.Password) < 6 { if hasBackendAccess(role) && len(dto.Password) < 6 {
return nil, errors.New("管理员和技术员必须设置至少 6 位初始密码") return nil, errors.New("工单处理账号必须设置至少 6 位初始密码")
} }
var existing models.User var existing models.User
@@ -133,7 +133,12 @@ func (s *UsersService) FindAll(page int, limit int, role string, search string)
db := database.DB.Model(&models.User{}).Preload("EmployeeSerials") db := database.DB.Model(&models.User{}).Preload("EmployeeSerials")
if role != "" { if role != "" {
if !models.IsAssignableWorkOrderRole(role) {
return []models.UserDTO{}, 0, 0, nil
}
db = db.Where("role = ?", role) db = db.Where("role = ?", role)
} else {
db = db.Where("role IN ?", models.AssignableWorkOrderRoles)
} }
if search != "" { if search != "" {
pattern := "%" + search + "%" pattern := "%" + search + "%"
@@ -159,11 +164,11 @@ func (s *UsersService) FindAll(page int, limit int, role string, search string)
return result, int(total), totalPages, nil return result, int(total), totalPages, nil
} }
// FindAssignable 获取可分配的用户(admin + technician,用于售后工单分配 // FindAssignable 获取可分配的工单处理人员,用于工单分配
func (s *UsersService) FindAssignable() ([]models.UserDTO, error) { func (s *UsersService) FindAssignable() ([]models.UserDTO, error) {
var users []models.User var users []models.User
if err := database.DB.Where("role IN ?", []string{"admin", "technician"}). if err := database.DB.Where("role IN ?", models.AssignableWorkOrderRoles).
Order("role DESC, created_at ASC").Find(&users).Error; err != nil { Order("created_at ASC").Find(&users).Error; err != nil {
return nil, fmt.Errorf("查询可分配用户失败: %w", err) return nil, fmt.Errorf("查询可分配用户失败: %w", err)
} }
result := make([]models.UserDTO, 0, len(users)) result := make([]models.UserDTO, 0, len(users))
@@ -206,8 +211,11 @@ func (s *UsersService) Update(userId uint, dto models.UpdateUserDTO, currentUser
user.Position = strings.TrimSpace(dto.Position) user.Position = strings.TrimSpace(dto.Position)
} }
if dto.Role != "" { if dto.Role != "" {
if !isValidEmployeeRole(dto.Role) { if !isValidManagedRole(dto.Role) {
return nil, errors.New("角色不正确") return nil, errors.New("权限管理只能设置软件工程师、硬件工程师、商务经理、项目经理")
}
if user.Role == "admin" {
return nil, errors.New("不能通过权限管理修改管理员角色")
} }
// 防止管理员把自己降级 // 防止管理员把自己降级
if user.ID == currentUserId && user.Role == "admin" && dto.Role != "admin" { if user.ID == currentUserId && user.Role == "admin" && dto.Role != "admin" {
@@ -231,7 +239,7 @@ func (s *UsersService) ResetPassword(userId uint, newPassword string) error {
return errors.New("用户不存在") return errors.New("用户不存在")
} }
if !hasBackendAccess(user.Role) { if !hasBackendAccess(user.Role) {
return errors.New("员工无后台登录权限,不能设置密码") return errors.New("该账号无后台登录权限,不能设置密码")
} }
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+29 -42
View File
@@ -18,13 +18,13 @@ func TestUsersService_Create_Success(t *testing.T) {
Email: "new@example.com", Email: "new@example.com",
Phone: "13800000001", Phone: "13800000001",
EmployeeNo: "users_create_ok", EmployeeNo: "users_create_ok",
Position: "技术员", Position: "软件工程师",
Role: "technician", Role: "software_engineer",
}) })
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, dto) assert.NotNil(t, dto)
assert.Equal(t, "users_create_ok", dto.Username) assert.Equal(t, "users_create_ok", dto.Username)
assert.Equal(t, "technician", dto.Role) assert.Equal(t, "software_engineer", dto.Role)
assert.Equal(t, "13800000001", dto.Phone) assert.Equal(t, "13800000001", dto.Phone)
assert.Equal(t, "users_create_ok", dto.EmployeeNo) assert.Equal(t, "users_create_ok", dto.EmployeeNo)
assert.Len(t, dto.EmployeeSerials, 1) assert.Len(t, dto.EmployeeSerials, 1)
@@ -33,35 +33,17 @@ func TestUsersService_Create_Success(t *testing.T) {
database.DB.Unscoped().Where("employee_name = ?", "新技术员").Delete(&models.EmployeeSerial{}) database.DB.Unscoped().Where("employee_name = ?", "新技术员").Delete(&models.EmployeeSerial{})
} }
func TestUsersService_Create_EmployeeWithoutPasswordGeneratesSerial(t *testing.T) { func TestUsersService_Create_BlocksEmployeeRole(t *testing.T) {
svc := UsersService{} svc := UsersService{}
dto, err := svc.Create(models.CreateUserDTO{ _, err := svc.Create(models.CreateUserDTO{
Name: "普通员工", Name: "普通员工",
Phone: "13800000002", Phone: "13800000002",
EmployeeNo: "employee_no_pwd", EmployeeNo: "employee_no_pwd",
Position: "生产员工", Position: "生产员工",
Role: "employee", Role: "employee",
}) })
assert.NoError(t, err) assert.Error(t, err)
assert.NotNil(t, dto) assert.Contains(t, err.Error(), "权限管理只能创建")
assert.Equal(t, "employee", dto.Role)
assert.Len(t, dto.EmployeeSerials, 1)
var user models.User
assert.NoError(t, database.DB.Where("employee_no = ?", "employee_no_pwd").First(&user).Error)
assert.Empty(t, user.Password)
serialService := EmployeeSerialsService{}
serial, err := serialService.Query(dto.EmployeeSerials[0].SerialNumber)
assert.NoError(t, err)
assert.NotNil(t, serial.Employee)
assert.Equal(t, "普通员工", serial.Employee.Name)
assert.Equal(t, "13800000002", serial.Employee.Phone)
assert.Equal(t, "employee_no_pwd", serial.Employee.EmployeeNo)
assert.Equal(t, "生产员工", serial.Employee.Position)
database.DB.Unscoped().Where("employee_id = ?", user.ID).Delete(&models.EmployeeSerial{})
database.DB.Unscoped().Delete(&user)
} }
func TestUsersService_Create_BackendRoleRequiresPassword(t *testing.T) { func TestUsersService_Create_BackendRoleRequiresPassword(t *testing.T) {
@@ -70,8 +52,8 @@ func TestUsersService_Create_BackendRoleRequiresPassword(t *testing.T) {
Name: "无密码技术员", Name: "无密码技术员",
Phone: "13800000003", Phone: "13800000003",
EmployeeNo: "tech_no_pwd", EmployeeNo: "tech_no_pwd",
Position: "技术员", Position: "软件工程师",
Role: "technician", Role: "software_engineer",
}) })
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "必须设置") assert.Contains(t, err.Error(), "必须设置")
@@ -82,7 +64,7 @@ func TestUsersService_Create_DuplicateUsername(t *testing.T) {
Username: "users_create_dup", Username: "users_create_dup",
Password: "x", Password: "x",
Name: "existing", Name: "existing",
Role: "technician", Role: "software_engineer",
} }
database.DB.Create(&user) database.DB.Create(&user)
defer database.DB.Unscoped().Delete(&user) defer database.DB.Unscoped().Delete(&user)
@@ -94,8 +76,8 @@ func TestUsersService_Create_DuplicateUsername(t *testing.T) {
Name: "duplicate", Name: "duplicate",
Phone: "13800000004", Phone: "13800000004",
EmployeeNo: "users_create_dup_2", EmployeeNo: "users_create_dup_2",
Position: "技术员", Position: "软件工程师",
Role: "technician", Role: "software_engineer",
}) })
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "用户名已存在") assert.Contains(t, err.Error(), "用户名已存在")
@@ -113,10 +95,10 @@ func TestUsersService_Update_BlocksSelfDemotion(t *testing.T) {
svc := UsersService{} svc := UsersService{}
_, err := svc.Update(admin.ID, models.UpdateUserDTO{ _, err := svc.Update(admin.ID, models.UpdateUserDTO{
Role: "technician", Role: "software_engineer",
}, admin.ID) }, admin.ID)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "不能修改自己的管理员角色") assert.Contains(t, err.Error(), "不能通过权限管理修改管理员角色")
} }
func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) { func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
@@ -130,7 +112,7 @@ func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
Username: "users_update_target", Username: "users_update_target",
Password: "x", Password: "x",
Name: "target", Name: "target",
Role: "technician", Role: "software_engineer",
} }
database.DB.Create(&currentAdmin) database.DB.Create(&currentAdmin)
database.DB.Create(&target) database.DB.Create(&target)
@@ -140,11 +122,11 @@ func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
svc := UsersService{} svc := UsersService{}
updated, err := svc.Update(target.ID, models.UpdateUserDTO{ updated, err := svc.Update(target.ID, models.UpdateUserDTO{
Name: "新名字", Name: "新名字",
Role: "admin", Role: "project_manager",
}, currentAdmin.ID) }, currentAdmin.ID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "新名字", updated.Name) assert.Equal(t, "新名字", updated.Name)
assert.Equal(t, "admin", updated.Role) assert.Equal(t, "project_manager", updated.Role)
} }
func TestUsersService_ResetPassword_ChangesHash(t *testing.T) { func TestUsersService_ResetPassword_ChangesHash(t *testing.T) {
@@ -153,7 +135,7 @@ func TestUsersService_ResetPassword_ChangesHash(t *testing.T) {
Username: "users_reset_pwd", Username: "users_reset_pwd",
Password: string(hashed), Password: string(hashed),
Name: "reset", Name: "reset",
Role: "technician", Role: "software_engineer",
} }
database.DB.Create(&user) database.DB.Create(&user)
defer database.DB.Unscoped().Delete(&user) defer database.DB.Unscoped().Delete(&user)
@@ -214,15 +196,18 @@ func TestUsersService_Delete_BlocksLastAdmin(t *testing.T) {
database.DB.Unscoped().Delete(&last) database.DB.Unscoped().Delete(&last)
} }
func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) { func TestUsersService_FindAssignable_ReturnsWorkOrderRoles(t *testing.T) {
a := models.User{Username: "assignable_admin", Password: "x", Name: "A", Role: "admin"} a := models.User{Username: "assignable_admin", Password: "x", Name: "A", Role: "admin"}
tech := models.User{Username: "assignable_tech", Password: "x", Name: "T", Role: "technician"} tech := models.User{Username: "assignable_tech", Password: "x", Name: "T", Role: "technician"}
software := models.User{Username: "assignable_software", Password: "x", Name: "S", Role: "software_engineer"}
plain := models.User{Username: "assignable_user", Password: "x", Name: "U", Role: "employee"} plain := models.User{Username: "assignable_user", Password: "x", Name: "U", Role: "employee"}
database.DB.Create(&a) database.DB.Create(&a)
database.DB.Create(&tech) database.DB.Create(&tech)
database.DB.Create(&software)
database.DB.Create(&plain) database.DB.Create(&plain)
defer database.DB.Unscoped().Delete(&a) defer database.DB.Unscoped().Delete(&a)
defer database.DB.Unscoped().Delete(&tech) defer database.DB.Unscoped().Delete(&tech)
defer database.DB.Unscoped().Delete(&software)
defer database.DB.Unscoped().Delete(&plain) defer database.DB.Unscoped().Delete(&plain)
svc := UsersService{} svc := UsersService{}
@@ -233,15 +218,17 @@ func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) {
for _, u := range users { for _, u := range users {
usernames[u.Username] = u.Role usernames[u.Username] = u.Role
} }
assert.Equal(t, "admin", usernames["assignable_admin"])
assert.Equal(t, "technician", usernames["assignable_tech"]) assert.Equal(t, "technician", usernames["assignable_tech"])
assert.Equal(t, "software_engineer", usernames["assignable_software"])
_, hasAdmin := usernames["assignable_admin"]
assert.False(t, hasAdmin, "admin should not be assignable")
_, hasPlain := usernames["assignable_user"] _, hasPlain := usernames["assignable_user"]
assert.False(t, hasPlain, "plain user should not be assignable") assert.False(t, hasPlain, "plain user should not be assignable")
} }
func TestUsersService_FindAll_FilterByRole(t *testing.T) { func TestUsersService_FindAll_FilterByRole(t *testing.T) {
tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "technician"} tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "software_engineer"}
tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", Role: "technician"} tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", Role: "software_engineer"}
user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "employee"} user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "employee"}
database.DB.Create(&tech1) database.DB.Create(&tech1)
database.DB.Create(&tech2) database.DB.Create(&tech2)
@@ -251,9 +238,9 @@ func TestUsersService_FindAll_FilterByRole(t *testing.T) {
defer database.DB.Unscoped().Delete(&user1) defer database.DB.Unscoped().Delete(&user1)
svc := UsersService{} svc := UsersService{}
results, _, _, err := svc.FindAll(1, 50, "technician", "") results, _, _, err := svc.FindAll(1, 50, "software_engineer", "")
assert.NoError(t, err) assert.NoError(t, err)
for _, u := range results { for _, u := range results {
assert.Equal(t, "technician", u.Role) assert.Equal(t, "software_engineer", u.Role)
} }
} }