Compare commits

...

4 Commits

Author SHA1 Message Date
Frudrax Cheng f80ab4a8ce fix: avoid company writes for employee serials 2026-06-06 14:07:17 +08:00
Frudrax Cheng a9aa4a1318 feat: update project order types 2026-06-06 13:58:57 +08:00
Frudrax Cheng 5edb25ac4e feat: restrict permission roles 2026-06-06 13:50:56 +08:00
Frudrax Cheng a55f515930 docs: rename permission module 2026-06-06 13:36:55 +08:00
19 changed files with 279 additions and 214 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`
- **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** (技术员+管理员): `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** (仅管理员): `POST /api/aftersales/:serialNumber/reassign`, `POST /api/aftersales/:serialNumber/force-close`, `DELETE /api/aftersales/:serialNumber`
- **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`, `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** (技术员+管理员): `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** (仅管理员): `POST /api/project-orders/:serialNumber/reassign`, `POST /api/project-orders/:serialNumber/force-close`, `DELETE /api/project-orders/:serialNumber`
- **Users** (技术员+管理员): `GET /api/users/assignable`
- **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`, `POST /api/project-orders/:serialNumber/reassign`, `POST /api/project-orders/:serialNumber/force-close`, `DELETE /api/project-orders/:serialNumber`
- **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`
### Roles and permissions
- Roles are limited to `admin`, `technician`, and `employee`.
- `admin` has full backend access.
- `technician` only has aftersales/work-order module access.
- `employee` has no backend login access and does not require a password.
- `admin` is the system administrator role and has full backend access.
- Permission management may create/edit only four equal work-order roles: `software_engineer`, `hardware_engineer`, `business_manager`, `project_manager`.
- The four work-order roles can log in only to process aftersales/project orders assigned to themselves.
- 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`.
- `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
- 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
- **JWTAuthMiddleware**: Validates JWT tokens, sets user in context
- **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)`
### Git Hooks
+31 -27
View File
@@ -1,6 +1,6 @@
# 浙江贝凡溯源赋码平台 - 后端服务 (Go 版本)
这是一个使用 Go 语言开发的溯源赋码平台后端服务,提供权限下发、产品溯源、项目工单、售后工单等功能。
这是一个使用 Go 语言开发的溯源赋码平台后端服务,提供权限管理、产品溯源、项目工单、售后工单等功能。
## 技术栈
@@ -46,7 +46,7 @@ backend-go/
├── logger/ # 日志管理
│ └── logger.go # 结构化日志(使用 Zap)
├── middleware/ # 中间件层
│ └── auth.go # JWT 认证、管理员/技术员权限检查
│ └── auth.go # JWT 认证、管理员/工单角色权限检查
├── models/ # 数据模型和 DTO
│ └── models.go # User、Company、EmployeeSerial、ProductTrace、AftersalesOrder、ProjectOrder 等模型定义
├── routes/ # 路由配置
@@ -222,7 +222,7 @@ http://localhost:3000/swagger/index.html
- **交互式测试**: 直接在浏览器中测试 API 端点
- **请求/响应示例**: 查看每个接口的请求参数和响应格式
- **认证支持**: 支持 Bearer Token 认证,可以输入 JWT 令牌进行测试
- **按分组浏览**: API 按功能模块分组(认证、控制台、权限下发、产品溯源、项目工单、售后工单等)
- **按分组浏览**: API 按功能模块分组(认证、控制台、权限管理、产品溯源、项目工单、售后工单等)
### 重新生成 Swagger 文档
@@ -256,7 +256,7 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
| ---- | ---- | ---- | -------- | ---- |
| GET | `/api/dashboard/stats` | 获取工单统计和最近售后工单 | 是 | 管理员 |
### 权限下发(员工赋码)
### 权限管理(员工赋码)
| 方法 | 路径 | 描述 | 需要认证 | 角色 |
| ---- | -------------------------------------- | ------------------ | -------- | ------ |
@@ -298,21 +298,22 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
| GET | `/api/project-orders/:serialNumber/query` | 公开查询项目工单 | 否 | 任何 |
| POST | `/api/project-orders/:serialNumber/site-images` | 上传现场图片 | 否 | 任何 |
| POST | `/api/project-orders/:serialNumber/complete` | 工程师提交完成 | 否 | 任何 |
| 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` | 后台提交项目完成 | 是 | 管理员/技术员 |
| 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` | 后台提交项目完成 | 是 | 管理员/工单角色 |
| POST | `/api/project-orders/:serialNumber/reassign` | 工单分配 | 是 | 管理员 |
| POST | `/api/project-orders/:serialNumber/force-close` | 强制完成 | 是 | 管理员 |
| DELETE | `/api/project-orders/:serialNumber` | 删除项目工单 | 是 | 管理员 |
**项目工单特点**:
- 用于现场勘查、现场实施等项目任务
- 用于项目勘察、工程实施、定期维保、商务合作等项目任务
- 工单号格式: `zjbf-xm-YYMMDDNN`
- 现场图片最多 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` | 公开查询工单(脱敏) | 否 | 任何 |
| POST | `/api/aftersales/:serialNumber/confirm` | 客户授权/未授权确认 | 否 | 任何 |
| POST | `/api/aftersales` | 创建售后工单 | 是 | 管理员/技术员 |
| GET | `/api/aftersales` | 工单列表(支持筛选) | 是 | 管理员/技术员 |
| GET | `/api/aftersales/:serialNumber` | 工单详情 | 是 | 管理员/技术员 |
| PATCH | `/api/aftersales/:serialNumber` | 更新工单(仅负责人或管理员)| 是 | 管理员/技术员 |
| POST | `/api/aftersales/:serialNumber/qrcode` | 生成工单二维码 | 是 | 管理员/技术员 |
| POST | `/api/aftersales/:serialNumber/submit` | 提交客户确认 | 是 | 管理员/技术员 |
| POST | `/api/aftersales/:serialNumber/reassign` | 工单分配(重新分配技术员 | 是 | 管理员 |
| POST | `/api/aftersales` | 创建售后工单 | 是 | 管理员 |
| GET | `/api/aftersales` | 工单列表(支持筛选) | 是 | 管理员/工单角色 |
| GET | `/api/aftersales/:serialNumber` | 工单详情 | 是 | 管理员/工单角色 |
| PATCH | `/api/aftersales/:serialNumber` | 更新工单(仅负责人或管理员)| 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/qrcode` | 生成工单二维码 | 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/submit` | 提交客户确认 | 是 | 管理员/工单角色 |
| POST | `/api/aftersales/:serialNumber/reassign` | 工单分配(重新分配工单负责人) | 是 | 管理员 |
| POST | `/api/aftersales/:serialNumber/force-close` | 强制关闭工单 | 是 | 管理员 |
| 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`
- 序号按天重置,软删除工单不释放编号(避免回收造成混淆)
- 工单里的企业名称是售后客户信息,只保存在工单中
- 软件工程师、硬件工程师、商务经理、项目经理只能处理管理员分配给自己的售后工单
- 二维码扫码后客户在网页签名(canvas)后点「已授权」确认;选择「未授权」需填写退回原因
- 签名以 PNG dataURL 形式持久化到工单(`signature` 字段),管理员详情页可查看留底
- 签名校验:必须为 `data:image/png;base64,``data:image/jpeg;base64,` 前缀,解码后 200B500KB
@@ -343,26 +345,28 @@ go run github.com/swaggo/swag/cmd/swag@v1.16.6 init -g main.go
- 工单状态机: `created``pending_confirmation``closed` / `rejected`,被退回后可重新提交
- 公开查询不返回手机号(脱敏)
### 权限下发(仅管理员)
### 权限管理(仅管理员)
| 方法 | 路径 | 描述 | 需要认证 | 角色 |
| ------ | ----------------------------------- | -------------------------- | -------- | ------------- |
| GET | `/api/users/assignable` | 可分配用户列表(用于售后) | 是 | 管理员/技术员 |
| GET | `/api/users/assignable` | 可分配工单负责人列表 | 是 | 管理员 |
| POST | `/api/employees` | 创建员工并自动生成员工码 | 是 | 管理员 |
| GET | `/api/employees` | 员工列表(分页+筛选) | 是 | 管理员 |
| PATCH | `/api/employees/:id` | 更新员工姓名/电话/工号/岗位/角色 | 是 | 管理员 |
| POST | `/api/employees/:id/reset-password` | 重置后台账号密码 | 是 | 管理员 |
| DELETE | `/api/employees/:id` | 删除员工 | 是 | 管理员 |
**员工角色**:
- `admin`:管理员,拥有全部后台权限,包括权限下发、产品溯源、工单分配、强制关闭和删除工单
- `technician`:技术员,仅拥有工单模块权限,可创建/处理工单,可使用 `assignable` 查询可分配同事
- `employee`:员工,无后台登录权限,不需要密码,仅用于员工主档和员工码查询
**角色规则**:
- `admin`系统管理员,拥有全部后台权限,包括权限管理、产品溯源、创建工单、工单分配、强制关闭和删除工单
- 权限管理只能创建/编辑四个对等工单角色:`software_engineer`(软件工程师)、`hardware_engineer`(硬件工程师)、`business_manager`(商务经理)、`project_manager`(项目经理)
- 四个工单角色只能登录后台处理管理员分配给自己的项目工单和售后工单,不能访问控制台、权限管理、产品溯源等其他模块
- `technician` 是旧数据兼容角色,不再作为新建选项
- `employee` 是旧普通员工角色,无后台登录权限,不再作为新建选项
**保护规则**:
- 不能删除自己;不能将自己的 admin 角色降级;不能删除最后一个 admin
- 创建 `admin` / `technician` 必须设置初始密码,密码 bcrypt 加密存储
- 创建 `employee` 不要求密码,且不能登录后台
- 创建四个工单角色必须设置初始密码,密码 bcrypt 加密存储
- 不允许通过权限管理创建 `admin``employee`
## 测试
@@ -395,7 +399,7 @@ go tool cover -html=coverage.out
- 售后工单测试(YYMMDDNN 序号生成、状态机、客户确认手机号校验、强制关闭)
- 项目工单测试(创建、完成、现场图片、工程师签名)
- 产品溯源测试(创建、查询、二维码、公众号二维码)
- 权限下发测试(创建员工自动生成员工码、重复工号、自降级保护、最后管理员保护、密码重置)
- 权限管理测试(创建员工自动生成员工码、重复工号、自降级保护、最后管理员保护、密码重置)
- **tests/**: 集成测试(健康检查、登录流程)
## 代码检查
+7 -7
View File
@@ -59,7 +59,7 @@ func (c *AftersalesController) Create(ctx *gin.Context) {
// FindAll 获取售后工单列表
// @Summary 获取售后工单列表
// @Description 支持分页、搜索、按状态/服务类型/技术员筛选
// @Description 支持分页、搜索、按状态/服务类型/工单负责人筛选
// @Tags 售后工单
// @Produce json
// @Security BearerAuth
@@ -68,7 +68,7 @@ func (c *AftersalesController) Create(ctx *gin.Context) {
// @Param search query string false "搜索关键词"
// @Param workOrderStatus 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 "仅查看自己负责的工单"
// @Success 200 {object} models.PaginationResponse
// @Failure 401 {object} models.ErrorResponse
@@ -179,9 +179,9 @@ func (c *AftersalesController) Update(ctx *gin.Context) {
})
}
// SubmitForConfirmation 技术员提交客户确认
// SubmitForConfirmation 工单负责人提交客户确认
// @Summary 提交客户确认
// @Description 技术员填写处理结果后提交,工单进入"待客户确认"状态
// @Description 工单负责人填写处理结果后提交,工单进入"待客户确认"状态
// @Tags 售后工单
// @Accept json
// @Produce json
@@ -257,14 +257,14 @@ func (c *AftersalesController) GenerateQRCode(ctx *gin.Context) {
})
}
// Reassign 重新分配技术员(仅管理员)
// @Summary 重新分配技术员
// Reassign 重新分配工单负责人(仅管理员)
// @Summary 重新分配工单负责人
// @Tags 售后工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.ReassignAftersalesDTO true "新技术员 ID"
// @Param data body models.ReassignAftersalesDTO true "新工单负责人 ID"
// @Success 200 {object} models.DataResponse
// @Failure 400 {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())
return
}
if user.Role == "employee" {
ErrorResponse(ctx, http.StatusForbidden, "员工账号无后台登录权限")
if !models.HasBackendAccess(user.Role) {
ErrorResponse(ctx, http.StatusForbidden, "账号无后台登录权限")
return
}
+7 -7
View File
@@ -59,7 +59,7 @@ func (c *ProjectOrdersController) Create(ctx *gin.Context) {
// FindAll 获取项目工单列表
// @Summary 获取项目工单列表
// @Description 支持分页、搜索、按状态/服务类型/技术员筛选
// @Description 支持分页、搜索、按状态/服务类型/工单负责人筛选
// @Tags 项目工单
// @Produce json
// @Security BearerAuth
@@ -68,7 +68,7 @@ func (c *ProjectOrdersController) Create(ctx *gin.Context) {
// @Param search query string false "搜索关键词"
// @Param workOrderStatus 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 "仅查看自己负责的工单"
// @Success 200 {object} models.PaginationResponse
// @Failure 401 {object} models.ErrorResponse
@@ -179,9 +179,9 @@ func (c *ProjectOrdersController) Update(ctx *gin.Context) {
})
}
// SubmitCompletion 技术员提交完成资料
// SubmitCompletion 工单负责人提交完成资料
// @Summary 提交完成确认
// @Description 技术员填写处理结果后提交,工单进入"待完成确认"状态
// @Description 工单负责人填写处理结果后提交,工单进入"待完成确认"状态
// @Tags 项目工单
// @Accept json
// @Produce json
@@ -257,14 +257,14 @@ func (c *ProjectOrdersController) GenerateQRCode(ctx *gin.Context) {
})
}
// Reassign 重新分配技术员(仅管理员)
// @Summary 重新分配技术员
// Reassign 重新分配工单负责人(仅管理员)
// @Summary 重新分配工单负责人
// @Tags 项目工单
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param serialNumber path string true "工单号"
// @Param data body models.ReassignProjectOrderDTO true "新技术员 ID"
// @Param data body models.ReassignProjectOrderDTO true "新工单负责人 ID"
// @Success 200 {object} models.DataResponse
// @Failure 400 {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
// @Summary 获取可分配用户列表
// @Description 用于售后工单分配选择技术员/管理员,无需分页
// @Description 用于工单分配选择可派单人员,无需分页
// @Tags 用户管理
// @Produce json
// @Security BearerAuth
+21 -17
View File
@@ -31,7 +31,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选",
"description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [
"application/json"
],
@@ -72,7 +72,7 @@ const docTemplate = `{
},
{
"type": "integer",
"description": "技术员 ID",
"description": "工单负责人 ID",
"name": "technicianId",
"in": "query"
},
@@ -519,7 +519,7 @@ const docTemplate = `{
"tags": [
"售后工单"
],
"summary": "重新分配技术员",
"summary": "重新分配工单负责人",
"parameters": [
{
"type": "string",
@@ -529,7 +529,7 @@ const docTemplate = `{
"required": true
},
{
"description": "新技术员 ID",
"description": "新工单负责人 ID",
"name": "data",
"in": "body",
"required": true,
@@ -617,7 +617,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "技术员填写处理结果后提交,工单进入\"待客户确认\"状态",
"description": "工单负责人填写处理结果后提交,工单进入\"待客户确认\"状态",
"consumes": [
"application/json"
],
@@ -1558,7 +1558,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选",
"description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [
"application/json"
],
@@ -1599,7 +1599,7 @@ const docTemplate = `{
},
{
"type": "integer",
"description": "技术员 ID",
"description": "工单负责人 ID",
"name": "technicianId",
"in": "query"
},
@@ -2046,7 +2046,7 @@ const docTemplate = `{
"tags": [
"项目工单"
],
"summary": "重新分配技术员",
"summary": "重新分配工单负责人",
"parameters": [
{
"type": "string",
@@ -2056,7 +2056,7 @@ const docTemplate = `{
"required": true
},
{
"description": "新技术员 ID",
"description": "新工单负责人 ID",
"name": "data",
"in": "body",
"required": true,
@@ -2144,7 +2144,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "技术员填写处理结果后提交,工单进入\"待完成确认\"状态",
"description": "工单负责人填写处理结果后提交,工单进入\"待完成确认\"状态",
"consumes": [
"application/json"
],
@@ -2202,7 +2202,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "用于售后工单分配选择技术员/管理员,无需分页",
"description": "用于工单分配选择可派单人员,无需分页",
"produces": [
"application/json"
],
@@ -2352,6 +2352,7 @@ const docTemplate = `{
"survey",
"implementation",
"maintenance",
"business",
"other"
]
},
@@ -2394,9 +2395,10 @@ const docTemplate = `{
"role": {
"type": "string",
"enum": [
"admin",
"technician",
"employee"
"software_engineer",
"hardware_engineer",
"business_manager",
"project_manager"
]
},
"username": {
@@ -2742,6 +2744,7 @@ const docTemplate = `{
"survey",
"implementation",
"maintenance",
"business",
"other"
]
},
@@ -2774,9 +2777,10 @@ const docTemplate = `{
"role": {
"type": "string",
"enum": [
"admin",
"technician",
"employee"
"software_engineer",
"hardware_engineer",
"business_manager",
"project_manager"
]
}
}
+21 -17
View File
@@ -25,7 +25,7 @@
"BearerAuth": []
}
],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选",
"description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [
"application/json"
],
@@ -66,7 +66,7 @@
},
{
"type": "integer",
"description": "技术员 ID",
"description": "工单负责人 ID",
"name": "technicianId",
"in": "query"
},
@@ -513,7 +513,7 @@
"tags": [
"售后工单"
],
"summary": "重新分配技术员",
"summary": "重新分配工单负责人",
"parameters": [
{
"type": "string",
@@ -523,7 +523,7 @@
"required": true
},
{
"description": "新技术员 ID",
"description": "新工单负责人 ID",
"name": "data",
"in": "body",
"required": true,
@@ -611,7 +611,7 @@
"BearerAuth": []
}
],
"description": "技术员填写处理结果后提交,工单进入\"待客户确认\"状态",
"description": "工单负责人填写处理结果后提交,工单进入\"待客户确认\"状态",
"consumes": [
"application/json"
],
@@ -1552,7 +1552,7 @@
"BearerAuth": []
}
],
"description": "支持分页、搜索、按状态/服务类型/技术员筛选",
"description": "支持分页、搜索、按状态/服务类型/工单负责人筛选",
"produces": [
"application/json"
],
@@ -1593,7 +1593,7 @@
},
{
"type": "integer",
"description": "技术员 ID",
"description": "工单负责人 ID",
"name": "technicianId",
"in": "query"
},
@@ -2040,7 +2040,7 @@
"tags": [
"项目工单"
],
"summary": "重新分配技术员",
"summary": "重新分配工单负责人",
"parameters": [
{
"type": "string",
@@ -2050,7 +2050,7 @@
"required": true
},
{
"description": "新技术员 ID",
"description": "新工单负责人 ID",
"name": "data",
"in": "body",
"required": true,
@@ -2138,7 +2138,7 @@
"BearerAuth": []
}
],
"description": "技术员填写处理结果后提交,工单进入\"待完成确认\"状态",
"description": "工单负责人填写处理结果后提交,工单进入\"待完成确认\"状态",
"consumes": [
"application/json"
],
@@ -2196,7 +2196,7 @@
"BearerAuth": []
}
],
"description": "用于售后工单分配选择技术员/管理员,无需分页",
"description": "用于工单分配选择可派单人员,无需分页",
"produces": [
"application/json"
],
@@ -2346,6 +2346,7 @@
"survey",
"implementation",
"maintenance",
"business",
"other"
]
},
@@ -2388,9 +2389,10 @@
"role": {
"type": "string",
"enum": [
"admin",
"technician",
"employee"
"software_engineer",
"hardware_engineer",
"business_manager",
"project_manager"
]
},
"username": {
@@ -2736,6 +2738,7 @@
"survey",
"implementation",
"maintenance",
"business",
"other"
]
},
@@ -2768,9 +2771,10 @@
"role": {
"type": "string",
"enum": [
"admin",
"technician",
"employee"
"software_engineer",
"hardware_engineer",
"business_manager",
"project_manager"
]
}
}
+21 -17
View File
@@ -80,6 +80,7 @@ definitions:
- survey
- implementation
- maintenance
- business
- other
type: string
siteDescription:
@@ -110,9 +111,10 @@ definitions:
type: string
role:
enum:
- admin
- technician
- employee
- software_engineer
- hardware_engineer
- business_manager
- project_manager
type: string
username:
type: string
@@ -347,6 +349,7 @@ definitions:
- survey
- implementation
- maintenance
- business
- other
type: string
siteDescription:
@@ -368,9 +371,10 @@ definitions:
type: string
role:
enum:
- admin
- technician
- employee
- software_engineer
- hardware_engineer
- business_manager
- project_manager
type: string
type: object
models.User:
@@ -441,7 +445,7 @@ info:
paths:
/aftersales:
get:
description: 支持分页、搜索、按状态/服务类型/技术员筛选
description: 支持分页、搜索、按状态/服务类型/工单负责人筛选
parameters:
- description: 页码
in: query
@@ -463,7 +467,7 @@ paths:
in: query
name: serviceType
type: string
- description: 技术员 ID
- description: 工单负责人 ID
in: query
name: technicianId
type: integer
@@ -754,7 +758,7 @@ paths:
name: serialNumber
required: true
type: string
- description: 技术员 ID
- description: 工单负责人 ID
in: body
name: data
required: true
@@ -777,7 +781,7 @@ paths:
$ref: '#/definitions/models.ErrorResponse'
security:
- BearerAuth: []
summary: 重新分配技术员
summary: 重新分配工单负责人
tags:
- 售后工单
/aftersales/{serialNumber}/site-images:
@@ -817,7 +821,7 @@ paths:
post:
consumes:
- application/json
description: 技术员填写处理结果后提交,工单进入"待客户确认"状态
description: 工单负责人填写处理结果后提交,工单进入"待客户确认"状态
parameters:
- description: 工单号
in: path
@@ -1412,7 +1416,7 @@ paths:
- 员工管理
/project-orders:
get:
description: 支持分页、搜索、按状态/服务类型/技术员筛选
description: 支持分页、搜索、按状态/服务类型/工单负责人筛选
parameters:
- description: 页码
in: query
@@ -1434,7 +1438,7 @@ paths:
in: query
name: serviceType
type: string
- description: 技术员 ID
- description: 工单负责人 ID
in: query
name: technicianId
type: integer
@@ -1725,7 +1729,7 @@ paths:
name: serialNumber
required: true
type: string
- description: 技术员 ID
- description: 工单负责人 ID
in: body
name: data
required: true
@@ -1748,7 +1752,7 @@ paths:
$ref: '#/definitions/models.ErrorResponse'
security:
- BearerAuth: []
summary: 重新分配技术员
summary: 重新分配工单负责人
tags:
- 项目工单
/project-orders/{serialNumber}/site-images:
@@ -1788,7 +1792,7 @@ paths:
post:
consumes:
- application/json
description: 技术员填写处理结果后提交,工单进入"待完成确认"状态
description: 工单负责人填写处理结果后提交,工单进入"待完成确认"状态
parameters:
- description: 工单号
in: path
@@ -1823,7 +1827,7 @@ paths:
- 项目工单
/users/assignable:
get:
description: 用于售后工单分配选择技术员/管理员,无需分页
description: 用于工单分配选择可派单人员,无需分页
produces:
- application/json
responses:
+2 -2
View File
@@ -111,7 +111,7 @@ func AdminMiddleware() gin.HandlerFunc {
}
}
// TechnicianMiddleware 技术员权限中间件(放行 admin 和 technician
// TechnicianMiddleware 工单处理权限中间件(放行 admin 和可派单角色
func TechnicianMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
user, exists := c.Get("user")
@@ -123,7 +123,7 @@ func TechnicianMiddleware() gin.HandlerFunc {
}
userModel := user.(models.User)
if userModel.Role != "admin" && userModel.Role != "technician" {
if !models.HasWorkOrderAccess(userModel.Role) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"message": "无权限访问此资源",
})
+52 -5
View File
@@ -6,6 +6,53 @@ import (
"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 模型
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -94,7 +141,7 @@ type CreateUserDTO struct {
Phone string `json:"phone" validate:"required"`
EmployeeNo string `json:"employeeNo" 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 管理员更新用户信息请求
@@ -104,7 +151,7 @@ type UpdateUserDTO struct {
Phone string `json:"phone,omitempty"`
EmployeeNo string `json:"employeeNo,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 管理员重置用户密码
@@ -295,7 +342,7 @@ type CustomerConfirmDTO struct {
RejectReason string `json:"rejectReason,omitempty" validate:"required_if=Action reject"`
}
// ReassignAftersalesDTO 重新分配技术员请求
// ReassignAftersalesDTO 重新分配工单负责人请求
type ReassignAftersalesDTO struct {
TechnicianID uint `json:"technicianId" validate:"required"`
}
@@ -354,7 +401,7 @@ type CreateProjectOrderDTO struct {
CompanyAddress string `json:"companyAddress" validate:"required"`
ContactName string `json:"contactName" validate:"required"`
ContactPhone string `json:"contactPhone" validate:"required,len=11"`
ProjectType string `json:"projectType" validate:"required,oneof=survey implementation maintenance other"`
ProjectType string `json:"projectType" validate:"required,oneof=survey implementation maintenance business other"`
SiteDescription string `json:"siteDescription" validate:"required"`
TechnicianID *uint `json:"technicianId,omitempty"`
}
@@ -364,7 +411,7 @@ type UpdateProjectOrderDTO struct {
CompanyAddress string `json:"companyAddress,omitempty"`
ContactName string `json:"contactName,omitempty"`
ContactPhone string `json:"contactPhone,omitempty" validate:"omitempty,len=11"`
ProjectType string `json:"projectType,omitempty" validate:"omitempty,oneof=survey implementation maintenance other"`
ProjectType string `json:"projectType,omitempty" validate:"omitempty,oneof=survey implementation maintenance business other"`
SiteDescription string `json:"siteDescription,omitempty"`
CompletionNote string `json:"completionNote,omitempty"`
TechnicianID *uint `json:"technicianId,omitempty"`
+3 -3
View File
@@ -80,7 +80,7 @@ func SetupAPIRoutes(r *gin.RouterGroup) {
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("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.Create)
aftersalesRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), aftersalesController.Create)
aftersalesRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindAll)
aftersalesRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), aftersalesController.FindOne)
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("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Create)
projectOrdersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), projectOrdersController.Create)
projectOrdersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindAll)
projectOrdersRoutes.GET("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.FindOne)
projectOrdersRoutes.PATCH("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), projectOrdersController.Update)
+6 -6
View File
@@ -334,7 +334,7 @@ func (s *AftersalesService) Update(
return &order, nil
}
// SubmitForConfirmation 技术员提交客户确认
// SubmitForConfirmation 工单负责人提交客户确认
func (s *AftersalesService) SubmitForConfirmation(
serialNumber string,
dto models.SubmitForConfirmationDTO,
@@ -539,7 +539,7 @@ func (s *AftersalesService) CustomerConfirm(serialNumber string, dto models.Cust
return s.PublicQuery(normalized)
}
// Reassign 重新分配技术员(仅管理员)
// Reassign 重新分配工单负责人(仅管理员)
func (s *AftersalesService) Reassign(serialNumber string, technicianID uint) (*models.AftersalesOrder, error) {
var order models.AftersalesOrder
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
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" {
return nil, errors.New("指定的用户不是技术员或管理员")
if !models.IsAssignableWorkOrderRole(technician.Role) {
return nil, errors.New("指定的用户不是可派单人员")
}
order.TechnicianID = &technicianID
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)
+1 -1
View File
@@ -23,7 +23,7 @@ func (s *AuthService) ValidateUser(username string, password string) (*models.Us
if result.Error != nil {
return nil, errors.New("用户名或密码不正确")
}
if user.Role != "admin" && user.Role != "technician" {
if !models.HasBackendAccess(user.Role) {
return nil, errors.New("用户名或密码不正确")
}
if user.Password == "" {
+1 -26
View File
@@ -79,20 +79,6 @@ func (s *EmployeeSerialsService) generateWithDB(
) ([]models.EmployeeSerial, error) {
var serials []models.EmployeeSerial
// 检查公司是否存在,不存在则创建
var company models.Company
result := db.Where("company_name = ?", companyName).First(&company)
if result.Error != nil {
company = models.Company{
CompanyName: companyName,
IsActive: true,
}
result = db.Create(&company)
if result.Error != nil {
return nil, fmt.Errorf("创建公司失败: %w", result.Error)
}
}
// 生成序列号前缀
if serialPrefix == "" {
serialPrefix = fmt.Sprintf("EMP%d", time.Now().Year()%100)
@@ -136,7 +122,7 @@ func (s *EmployeeSerialsService) generateWithDB(
}
// 保存到数据库
result = db.Create(&serials)
result := db.Create(&serials)
if result.Error != nil {
return nil, fmt.Errorf("保存员工序列号失败: %w", result.Error)
}
@@ -197,17 +183,6 @@ func (s *EmployeeSerialsService) Update(serialNumber string, updateData models.U
}
if updateData.CompanyName != "" {
// 检查公司是否存在
var company models.Company
companyResult := database.DB.Where("company_name = ?", updateData.CompanyName).First(&company)
if companyResult.Error != nil {
company = models.Company{
CompanyName: updateData.CompanyName,
IsActive: true,
}
database.DB.Create(&company)
}
serial.CompanyName = updateData.CompanyName
}
+6 -6
View File
@@ -213,7 +213,7 @@ func (s *ProjectOrdersService) Update(
return &order, nil
}
// SubmitCompletion 技术员提交完成资料,进入待完成确认状态
// SubmitCompletion 工单负责人提交完成资料,进入待完成确认状态
func (s *ProjectOrdersService) SubmitCompletion(
serialNumber string,
dto models.SubmitProjectCompletionDTO,
@@ -395,7 +395,7 @@ func (s *ProjectOrdersService) EngineerComplete(serialNumber string, dto models.
return s.PublicQuery(normalized)
}
// Reassign 重新分配技术员(仅管理员)
// Reassign 重新分配工单负责人(仅管理员)
func (s *ProjectOrdersService) Reassign(serialNumber string, technicianID uint) (*models.ProjectOrder, error) {
var order models.ProjectOrder
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
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" {
return nil, errors.New("指定的用户不是技术员或管理员")
if !models.IsAssignableWorkOrderRole(technician.Role) {
return nil, errors.New("指定的用户不是可派单人员")
}
order.TechnicianID = &technicianID
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)
+8 -6
View File
@@ -248,7 +248,7 @@ func TestEmployeeSerialsService_Generate_Success(t *testing.T) {
database.DB.Unscoped().Delete(&user)
}
func TestEmployeeSerialsService_Generate_CreateNewCompany(t *testing.T) {
func TestEmployeeSerialsService_Generate_DoesNotCreateManagedCompany(t *testing.T) {
var user models.User
password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost)
user = models.User{
@@ -266,11 +266,9 @@ func TestEmployeeSerialsService_Generate_CreateNewCompany(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, serials, 3)
var company models.Company
result := database.DB.Where("company_name = ?", "NewEmpCompany").First(&company)
assert.NoError(t, result.Error)
assert.Equal(t, "NewEmpCompany", company.CompanyName)
assert.True(t, company.IsActive)
var count int64
database.DB.Model(&models.Company{}).Where("company_name = ?", "NewEmpCompany").Count(&count)
assert.Equal(t, int64(0), count)
for _, serial := range serials {
database.DB.Unscoped().Delete(&serial)
@@ -419,6 +417,10 @@ func TestEmployeeSerialsService_Update_Success(t *testing.T) {
assert.Equal(t, "新名字", result.EmployeeName)
assert.False(t, result.IsActive)
var count int64
database.DB.Model(&models.Company{}).Where("company_name = ?", "UpdatedEmpCompany").Count(&count)
assert.Equal(t, int64(0), count)
for _, serial := range serials {
database.DB.Unscoped().Delete(&serial)
}
+20 -12
View File
@@ -31,11 +31,11 @@ func toUserDTO(user models.User) models.UserDTO {
}
func hasBackendAccess(role string) bool {
return role == "admin" || role == "technician"
return models.HasBackendAccess(role)
}
func isValidEmployeeRole(role string) bool {
return role == "admin" || role == "technician" || role == "employee"
func isValidManagedRole(role string) bool {
return models.IsWorkOrderRole(role)
}
// Create 创建用户
@@ -62,11 +62,11 @@ func (s *UsersService) Create(dto models.CreateUserDTO) (*models.UserDTO, error)
if position == "" {
return nil, errors.New("岗位不能为空")
}
if !isValidEmployeeRole(role) {
return nil, errors.New("角色不正确")
if !isValidManagedRole(role) {
return nil, errors.New("权限管理只能创建软件工程师、硬件工程师、商务经理、项目经理")
}
if hasBackendAccess(role) && len(dto.Password) < 6 {
return nil, errors.New("管理员和技术员必须设置至少 6 位初始密码")
return nil, errors.New("工单处理账号必须设置至少 6 位初始密码")
}
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")
if role != "" {
if !models.IsAssignableWorkOrderRole(role) {
return []models.UserDTO{}, 0, 0, nil
}
db = db.Where("role = ?", role)
} else {
db = db.Where("role IN ?", models.AssignableWorkOrderRoles)
}
if 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
}
// FindAssignable 获取可分配的用户(admin + technician,用于售后工单分配
// FindAssignable 获取可分配的工单处理人员,用于工单分配
func (s *UsersService) FindAssignable() ([]models.UserDTO, error) {
var users []models.User
if err := database.DB.Where("role IN ?", []string{"admin", "technician"}).
Order("role DESC, created_at ASC").Find(&users).Error; err != nil {
if err := database.DB.Where("role IN ?", models.AssignableWorkOrderRoles).
Order("created_at ASC").Find(&users).Error; err != nil {
return nil, fmt.Errorf("查询可分配用户失败: %w", err)
}
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)
}
if dto.Role != "" {
if !isValidEmployeeRole(dto.Role) {
return nil, errors.New("角色不正确")
if !isValidManagedRole(dto.Role) {
return nil, errors.New("权限管理只能设置软件工程师、硬件工程师、商务经理、项目经理")
}
if user.Role == "admin" {
return nil, errors.New("不能通过权限管理修改管理员角色")
}
// 防止管理员把自己降级
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("用户不存在")
}
if !hasBackendAccess(user.Role) {
return errors.New("员工无后台登录权限,不能设置密码")
return errors.New("该账号无后台登录权限,不能设置密码")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
+56 -41
View File
@@ -18,13 +18,13 @@ func TestUsersService_Create_Success(t *testing.T) {
Email: "new@example.com",
Phone: "13800000001",
EmployeeNo: "users_create_ok",
Position: "技术员",
Role: "technician",
Position: "软件工程师",
Role: "software_engineer",
})
assert.NoError(t, err)
assert.NotNil(t, dto)
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, "users_create_ok", dto.EmployeeNo)
assert.Len(t, dto.EmployeeSerials, 1)
@@ -33,35 +33,45 @@ func TestUsersService_Create_Success(t *testing.T) {
database.DB.Unscoped().Where("employee_name = ?", "新技术员").Delete(&models.EmployeeSerial{})
}
func TestUsersService_Create_EmployeeWithoutPasswordGeneratesSerial(t *testing.T) {
func TestUsersService_Create_DoesNotTouchCompanyTable(t *testing.T) {
database.DB.Unscoped().Where("company_name = ?", "内部员工").Delete(&models.Company{})
company := models.Company{CompanyName: "内部员工", IsActive: true}
assert.NoError(t, database.DB.Create(&company).Error)
assert.NoError(t, database.DB.Delete(&company).Error)
svc := UsersService{}
dto, err := svc.Create(models.CreateUserDTO{
Password: "password123",
Name: "软删公司员工",
Phone: "13800000008",
EmployeeNo: "users_create_restore_company",
Position: "硬件工程师",
Role: "hardware_engineer",
})
assert.NoError(t, err)
assert.NotNil(t, dto)
assert.Len(t, dto.EmployeeSerials, 1)
var count int64
database.DB.Model(&models.Company{}).Where("company_name = ?", "内部员工").Count(&count)
assert.Equal(t, int64(0), count)
database.DB.Unscoped().Where("username = ?", "users_create_restore_company").Delete(&models.User{})
database.DB.Unscoped().Where("employee_name = ?", "软删公司员工").Delete(&models.EmployeeSerial{})
database.DB.Unscoped().Where("company_name = ?", "内部员工").Delete(&models.Company{})
}
func TestUsersService_Create_BlocksEmployeeRole(t *testing.T) {
svc := UsersService{}
_, err := svc.Create(models.CreateUserDTO{
Name: "普通员工",
Phone: "13800000002",
EmployeeNo: "employee_no_pwd",
Position: "生产员工",
Role: "employee",
})
assert.NoError(t, err)
assert.NotNil(t, dto)
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)
assert.Error(t, err)
assert.Contains(t, err.Error(), "权限管理只能创建")
}
func TestUsersService_Create_BackendRoleRequiresPassword(t *testing.T) {
@@ -70,8 +80,8 @@ func TestUsersService_Create_BackendRoleRequiresPassword(t *testing.T) {
Name: "无密码技术员",
Phone: "13800000003",
EmployeeNo: "tech_no_pwd",
Position: "技术员",
Role: "technician",
Position: "软件工程师",
Role: "software_engineer",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "必须设置")
@@ -82,7 +92,7 @@ func TestUsersService_Create_DuplicateUsername(t *testing.T) {
Username: "users_create_dup",
Password: "x",
Name: "existing",
Role: "technician",
Role: "software_engineer",
}
database.DB.Create(&user)
defer database.DB.Unscoped().Delete(&user)
@@ -94,8 +104,8 @@ func TestUsersService_Create_DuplicateUsername(t *testing.T) {
Name: "duplicate",
Phone: "13800000004",
EmployeeNo: "users_create_dup_2",
Position: "技术员",
Role: "technician",
Position: "软件工程师",
Role: "software_engineer",
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "用户名已存在")
@@ -113,10 +123,10 @@ func TestUsersService_Update_BlocksSelfDemotion(t *testing.T) {
svc := UsersService{}
_, err := svc.Update(admin.ID, models.UpdateUserDTO{
Role: "technician",
Role: "software_engineer",
}, admin.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "不能修改自己的管理员角色")
assert.Contains(t, err.Error(), "不能通过权限管理修改管理员角色")
}
func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
@@ -130,7 +140,7 @@ func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
Username: "users_update_target",
Password: "x",
Name: "target",
Role: "technician",
Role: "software_engineer",
}
database.DB.Create(&currentAdmin)
database.DB.Create(&target)
@@ -140,11 +150,11 @@ func TestUsersService_Update_AllowsRoleChangeForOthers(t *testing.T) {
svc := UsersService{}
updated, err := svc.Update(target.ID, models.UpdateUserDTO{
Name: "新名字",
Role: "admin",
Role: "project_manager",
}, currentAdmin.ID)
assert.NoError(t, err)
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) {
@@ -153,7 +163,7 @@ func TestUsersService_ResetPassword_ChangesHash(t *testing.T) {
Username: "users_reset_pwd",
Password: string(hashed),
Name: "reset",
Role: "technician",
Role: "software_engineer",
}
database.DB.Create(&user)
defer database.DB.Unscoped().Delete(&user)
@@ -214,15 +224,18 @@ func TestUsersService_Delete_BlocksLastAdmin(t *testing.T) {
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"}
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"}
database.DB.Create(&a)
database.DB.Create(&tech)
database.DB.Create(&software)
database.DB.Create(&plain)
defer database.DB.Unscoped().Delete(&a)
defer database.DB.Unscoped().Delete(&tech)
defer database.DB.Unscoped().Delete(&software)
defer database.DB.Unscoped().Delete(&plain)
svc := UsersService{}
@@ -233,15 +246,17 @@ func TestUsersService_FindAssignable_ReturnsAdminAndTechnician(t *testing.T) {
for _, u := range users {
usernames[u.Username] = u.Role
}
assert.Equal(t, "admin", usernames["assignable_admin"])
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"]
assert.False(t, hasPlain, "plain user should not be assignable")
}
func TestUsersService_FindAll_FilterByRole(t *testing.T) {
tech1 := models.User{Username: "findall_tech1", Password: "x", Name: "T1", Role: "technician"}
tech2 := models.User{Username: "findall_tech2", Password: "x", Name: "T2", 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: "software_engineer"}
user1 := models.User{Username: "findall_user1", Password: "x", Name: "U1", Role: "employee"}
database.DB.Create(&tech1)
database.DB.Create(&tech2)
@@ -251,9 +266,9 @@ func TestUsersService_FindAll_FilterByRole(t *testing.T) {
defer database.DB.Unscoped().Delete(&user1)
svc := UsersService{}
results, _, _, err := svc.FindAll(1, 50, "technician", "")
results, _, _, err := svc.FindAll(1, 50, "software_engineer", "")
assert.NoError(t, err)
for _, u := range results {
assert.Equal(t, "technician", u.Role)
assert.Equal(t, "software_engineer", u.Role)
}
}