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) <noreply@anthropic.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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 加密存储
|
||||
|
||||
## 测试
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
@@ -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, "用户删除成功")
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user