From 3ddd4db1269466746c90f7291a6910afa6d8a1e7 Mon Sep 17 00:00:00 2001 From: Frudrax Cheng Date: Tue, 26 May 2026 10:57:53 +0800 Subject: [PATCH] Add user management for admin (CRUD + role + reset password) Adds /api/users endpoints (admin only) plus /api/users/assignable (admin + technician) used by the aftersales reassign picker. Guards prevent self-demotion, self-deletion, and removing the last admin. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 8 +- README.md | 26 +++- controllers/users_controller.go | 209 ++++++++++++++++++++++++++++++++ models/models.go | 21 ++++ routes/routes.go | 12 ++ services/users_service.go | 181 +++++++++++++++++++++++++++ 6 files changed, 453 insertions(+), 4 deletions(-) create mode 100644 controllers/users_controller.go create mode 100644 services/users_service.go diff --git a/AGENTS.md b/AGENTS.md index a431a79..c6e9aae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,8 @@ backend-go/ │ ├── companies_controller.go # Company CRUD │ ├── employees_controller.go # Employee serials: generate, query, update, revoke, qrcode │ ├── helper.go # Helper functions (GetCurrentUser, BindJSON, Response) -│ └── serials_controller.go # Company serials: generate, query, update, revoke, qrcode +│ ├── serials_controller.go # Company serials: generate, query, update, revoke, qrcode +│ └── users_controller.go # User management (admin): create, list, update, reset password, delete ├── database/ # Database connection and migrations │ └── database.go # GORM init, AutoMigrate ├── docs/ # Swagger documentation (auto-generated) @@ -67,7 +68,8 @@ backend-go/ │ ├── companies_service.go # Company CRUD │ ├── employees_service.go # Employee serials: generate, query, update, revoke, qrcode │ ├── serials_service.go # Company serials: generate, query, update, revoke, qrcode -│ └── services_test.go # Unit tests +│ ├── services_test.go # Unit tests +│ └── users_service.go # User CRUD, role management, password reset (admin) ├── tests/ # Integration tests │ └── main_test.go # End-to-end tests ├── data/ # SQLite data directory @@ -92,6 +94,8 @@ backend-go/ - **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` +- **Users** (技术员+管理员): `GET /api/users/assignable` +- **Users** (仅管理员): `POST /api/users`, `GET /api/users`, `PATCH /api/users/:id`, `POST /api/users/:id/reset-password`, `DELETE /api/users/:id` ### Import Organization Standard imports followed by third-party imports, then project imports (sorted alphabetically): diff --git a/README.md b/README.md index 489da17..561369d 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,8 @@ backend-go/ │ ├── companies_controller.go # 企业管理接口 │ ├── employees_controller.go # 员工赋码接口 │ ├── helper.go # 控制器通用辅助函数 -│ └── serials_controller.go # 序列号管理接口 +│ ├── serials_controller.go # 序列号管理接口 +│ └── users_controller.go # 用户管理接口(仅管理员) ├── database/ # 数据库连接和操作 │ └── database.go # 数据库初始化、连接池配置 ├── docs/ # Swagger API 文档(自动生成) @@ -55,7 +56,8 @@ backend-go/ │ ├── companies_service.go # 企业管理业务逻辑 │ ├── employees_service.go # 员工赋码业务逻辑 │ ├── serials_service.go # 序列号业务逻辑 -│ └── services_test.go # 服务层单元测试 +│ ├── services_test.go # 服务层单元测试 +│ └── users_service.go # 用户管理业务逻辑 ├── tests/ # 集成测试 │ └── main_test.go # 端到端测试 ├── data/ # 数据目录(SQLite 数据库存储位置) @@ -318,6 +320,26 @@ swag init -g main.go - 工单状态机: `created` → `pending_confirmation` → `closed` / `rejected`,被退回后可重新提交 - 公开查询不返回手机号(脱敏) +### 用户管理(仅管理员) + +| 方法 | 路径 | 描述 | 需要认证 | 角色 | +| ------ | ----------------------------------- | -------------------------- | -------- | ------------- | +| GET | `/api/users/assignable` | 可分配用户列表(用于售后) | 是 | 管理员/技术员 | +| POST | `/api/users` | 创建用户 | 是 | 管理员 | +| GET | `/api/users` | 用户列表(分页+筛选) | 是 | 管理员 | +| PATCH | `/api/users/:id` | 更新用户姓名/邮箱/角色 | 是 | 管理员 | +| POST | `/api/users/:id/reset-password` | 重置用户密码 | 是 | 管理员 | +| DELETE | `/api/users/:id` | 删除用户 | 是 | 管理员 | + +**用户角色**: +- `admin`:完整权限,包括用户管理、强制关闭工单、重新分配技术员、删除工单 +- `technician`:可创建/编辑自己负责的售后工单,可使用 `assignable` 查询同事 +- `user`:保留角色(暂未实际启用) + +**保护规则**: +- 不能删除自己;不能将自己的 admin 角色降级;不能删除最后一个 admin +- 默认创建用户密码 bcrypt 加密存储 + ## 测试 ### 运行所有测试 diff --git a/controllers/users_controller.go b/controllers/users_controller.go new file mode 100644 index 0000000..e52ac1e --- /dev/null +++ b/controllers/users_controller.go @@ -0,0 +1,209 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" + "git.beifan.cn/trace-system/backend-go/services" +) + +// UsersController 用户管理控制器 +type UsersController struct { + usersService services.UsersService +} + +// NewUsersController 创建用户管理控制器实例 +func NewUsersController() *UsersController { + return &UsersController{ + usersService: services.UsersService{}, + } +} + +// Create 创建用户(管理员) +// @Summary 创建用户 +// @Tags 用户管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param data body models.CreateUserDTO true "用户数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users [post] +func (c *UsersController) Create(ctx *gin.Context) { + var dto models.CreateUserDTO + if !BindJSON(ctx, &dto) { + return + } + + user, err := c.usersService.Create(dto) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "用户创建成功", gin.H{ + "user": user, + }) +} + +// FindAll 用户列表 +// @Summary 用户列表 +// @Tags 用户管理 +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Param role query string false "角色筛选" +// @Param search query string false "搜索" +// @Success 200 {object} models.PaginationResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users [get] +func (c *UsersController) FindAll(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + role := ctx.DefaultQuery("role", "") + search := ctx.DefaultQuery("search", "") + + users, total, totalPages, err := c.usersService.FindAll(page, limit, role, search) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取用户列表成功", gin.H{ + "data": users, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// FindAssignable 获取可分配的用户(admin + technician) +// @Summary 获取可分配用户列表 +// @Description 用于售后工单分配选择技术员/管理员,无需分页 +// @Tags 用户管理 +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.DataResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users/assignable [get] +func (c *UsersController) FindAssignable(ctx *gin.Context) { + users, err := c.usersService.FindAssignable() + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取可分配用户成功", gin.H{ + "data": users, + }) +} + +// Update 更新用户信息 +// @Summary 更新用户 +// @Tags 用户管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "用户 ID" +// @Param data body models.UpdateUserDTO true "更新数据" +// @Success 200 {object} models.DataResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users/{id} [patch] +func (c *UsersController) Update(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID") + return + } + + var dto models.UpdateUserDTO + if !BindJSON(ctx, &dto) { + return + } + + user, err := c.usersService.Update(uint(id), dto, userModel.ID) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "用户更新成功", gin.H{ + "user": user, + }) +} + +// ResetPassword 重置用户密码 +// @Summary 重置密码 +// @Tags 用户管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path int true "用户 ID" +// @Param data body models.AdminResetPasswordDTO true "新密码" +// @Success 200 {object} models.BaseResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users/{id}/reset-password [post] +func (c *UsersController) ResetPassword(ctx *gin.Context) { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID") + return + } + + var dto models.AdminResetPasswordDTO + if !BindJSON(ctx, &dto) { + return + } + + if err := c.usersService.ResetPassword(uint(id), dto.NewPassword); err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "密码重置成功") +} + +// Delete 删除用户 +// @Summary 删除用户 +// @Tags 用户管理 +// @Produce json +// @Security BearerAuth +// @Param id path int true "用户 ID" +// @Success 200 {object} models.BaseResponse +// @Failure 400 {object} models.ErrorResponse +// @Failure 401 {object} models.ErrorResponse +// @Router /users/{id} [delete] +func (c *UsersController) Delete(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, "无效的用户 ID") + return + } + + if err := c.usersService.Delete(uint(id), userModel.ID); err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "用户删除成功") +} diff --git a/models/models.go b/models/models.go index f7326a2..f4a7547 100644 --- a/models/models.go +++ b/models/models.go @@ -74,6 +74,27 @@ type UpdateProfileDTO struct { Email string `json:"email" validate:"required,email"` } +// CreateUserDTO 管理员创建用户请求 +type CreateUserDTO struct { + Username string `json:"username" validate:"required,min=3,max=50"` + Password string `json:"password" validate:"required,min=6"` + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"omitempty,email"` + Role string `json:"role" validate:"required,oneof=admin technician user"` +} + +// UpdateUserDTO 管理员更新用户信息请求 +type UpdateUserDTO struct { + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty" validate:"omitempty,email"` + Role string `json:"role,omitempty" validate:"omitempty,oneof=admin technician user"` +} + +// AdminResetPasswordDTO 管理员重置用户密码 +type AdminResetPasswordDTO struct { + NewPassword string `json:"newPassword" validate:"required,min=6"` +} + // GenerateSerialDTO 生成序列号请求数据 type GenerateSerialDTO struct { CompanyName string `json:"companyName" validate:"required"` diff --git a/routes/routes.go b/routes/routes.go index b0936c9..02258d0 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -74,6 +74,18 @@ func SetupAPIRoutes(r *gin.RouterGroup) { employeeSerialsRoutes.DELETE("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), employeeSerialsController.Delete) } + // 用户管理路由(仅管理员) + usersController := controllers.NewUsersController() + usersRoutes := r.Group("/users") + { + usersRoutes.GET("/assignable", middleware.JWTAuthMiddleware(), middleware.TechnicianMiddleware(), usersController.FindAssignable) + usersRoutes.POST("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Create) + usersRoutes.GET("", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.FindAll) + usersRoutes.PATCH("/:id", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Update) + usersRoutes.POST("/:id/reset-password", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.ResetPassword) + usersRoutes.DELETE("/:id", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), usersController.Delete) + } + // 售后工单路由 aftersalesController := controllers.NewAftersalesController() aftersalesRoutes := r.Group("/aftersales") diff --git a/services/users_service.go b/services/users_service.go new file mode 100644 index 0000000..a2d0bce --- /dev/null +++ b/services/users_service.go @@ -0,0 +1,181 @@ +package services + +import ( + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +// UsersService 用户管理服务 +type UsersService struct{} + +func toUserDTO(user models.User) models.UserDTO { + return models.UserDTO{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Email: user.Email, + Role: user.Role, + CreatedAt: user.CreatedAt, + } +} + +// Create 创建用户 +func (s *UsersService) Create(dto models.CreateUserDTO) (*models.UserDTO, error) { + username := strings.TrimSpace(dto.Username) + + var existing models.User + err := database.DB.Where("username = ?", username).First(&existing).Error + if err == nil { + return nil, errors.New("用户名已存在") + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("查询用户失败: %w", err) + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(dto.Password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %w", err) + } + + user := models.User{ + Username: username, + Password: string(hashed), + Name: dto.Name, + Email: dto.Email, + Role: dto.Role, + } + + if err := database.DB.Create(&user).Error; err != nil { + return nil, fmt.Errorf("创建用户失败: %w", err) + } + + dtoOut := toUserDTO(user) + return &dtoOut, nil +} + +// FindAll 分页 + 按角色过滤 +func (s *UsersService) FindAll(page int, limit int, role string, search string) ([]models.UserDTO, int, int, error) { + var users []models.User + var total int64 + + db := database.DB.Model(&models.User{}) + if role != "" { + db = db.Where("role = ?", role) + } + if search != "" { + pattern := "%" + search + "%" + db = db.Where("username LIKE ? OR name LIKE ? OR email LIKE ?", pattern, pattern, pattern) + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, 0, fmt.Errorf("统计用户数失败: %w", err) + } + + offset := (page - 1) * limit + if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil { + return nil, 0, 0, fmt.Errorf("查询用户列表失败: %w", err) + } + + result := make([]models.UserDTO, 0, len(users)) + for _, u := range users { + result = append(result, toUserDTO(u)) + } + + totalPages := (int(total) + limit - 1) / limit + return result, int(total), totalPages, nil +} + +// FindAssignable 获取可分配的用户(admin + technician),用于售后工单分配 +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 { + return nil, fmt.Errorf("查询可分配用户失败: %w", err) + } + result := make([]models.UserDTO, 0, len(users)) + for _, u := range users { + result = append(result, toUserDTO(u)) + } + return result, nil +} + +// Update 更新用户信息(不含密码) +func (s *UsersService) Update(userId uint, dto models.UpdateUserDTO, currentUserId uint) (*models.UserDTO, error) { + var user models.User + if err := database.DB.First(&user, userId).Error; err != nil { + return nil, errors.New("用户不存在") + } + + if dto.Name != "" { + user.Name = dto.Name + } + if dto.Email != "" { + user.Email = dto.Email + } + if dto.Role != "" { + // 防止管理员把自己降级 + if user.ID == currentUserId && user.Role == "admin" && dto.Role != "admin" { + return nil, errors.New("不能修改自己的管理员角色") + } + user.Role = dto.Role + } + + if err := database.DB.Save(&user).Error; err != nil { + return nil, fmt.Errorf("更新用户失败: %w", err) + } + + dtoOut := toUserDTO(user) + return &dtoOut, nil +} + +// ResetPassword 管理员重置用户密码 +func (s *UsersService) ResetPassword(userId uint, newPassword string) error { + var user models.User + if err := database.DB.First(&user, userId).Error; err != nil { + return errors.New("用户不存在") + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + + user.Password = string(hashed) + if err := database.DB.Save(&user).Error; err != nil { + return fmt.Errorf("重置密码失败: %w", err) + } + return nil +} + +// Delete 删除用户 +func (s *UsersService) Delete(userId uint, currentUserId uint) error { + if userId == currentUserId { + return errors.New("不能删除自己") + } + + var user models.User + if err := database.DB.First(&user, userId).Error; err != nil { + return errors.New("用户不存在") + } + + if user.Role == "admin" { + // 防止删除最后一个 admin + var adminCount int64 + database.DB.Model(&models.User{}).Where("role = ?", "admin").Count(&adminCount) + if adminCount <= 1 { + return errors.New("不能删除最后一个管理员") + } + } + + if err := database.DB.Delete(&user).Error; err != nil { + return fmt.Errorf("删除用户失败: %w", err) + } + return nil +}