package handler import ( "net/http" "strings" "unicode/utf8" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "kefu-sys/server/internal/middleware" "kefu-sys/server/internal/model" ) type StaffHandler struct{} func NewStaffHandler() *StaffHandler { return &StaffHandler{} } func requireStaffManager(c *gin.Context) bool { if middleware.HasAnyRole(c, "admin") { return true } c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"}) return false } // 坐席占用:本租户下 agent / supervisor / admin 均计 1 席(不含 disabled) func countActiveSeats(tenantID uint) (int64, error) { var n int64 err := model.DB.Model(&model.User{}). Where("tenant_id = ? AND role IN ? AND status <> ?", tenantID, []string{"agent", "supervisor", "admin"}, "disabled"). Count(&n).Error return n, err } func loadTenantSeatLimit(tenantID uint) (int, error) { var tenant model.Tenant if err := model.DB.Select("id", "seat_count").First(&tenant, tenantID).Error; err != nil { return 0, err } if tenant.SeatCount <= 0 { return 2, nil } return tenant.SeatCount, nil } type StaffItem struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Role string `json:"role"` Status string `json:"status"` CreatedAt string `json:"created_at"` LastOnlineAt string `json:"last_online_at,omitempty"` } func (h *StaffHandler) List(c *gin.Context) { if !middleware.HasAnyRole(c, "admin", "supervisor") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看坐席列表"}) return } tenantID := middleware.GetTenantID(c) var users []model.User if err := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, []string{"agent", "supervisor", "admin"}). Order("role asc, id asc").Find(&users).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询坐席失败"}) return } seatLimit, _ := loadTenantSeatLimit(tenantID) used, _ := countActiveSeats(tenantID) items := make([]StaffItem, 0, len(users)) for _, u := range users { item := StaffItem{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Role: u.Role, Status: u.Status, CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), } if u.LastOnlineAt != nil { item.LastOnlineAt = u.LastOnlineAt.Format("2006-01-02 15:04:05") } items = append(items, item) } middleware.JSON(c, gin.H{ "list": items, "seat_limit": seatLimit, "seat_used": used, }) } type CreateStaffReq struct { Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required"` Nickname string `json:"nickname"` Role string `json:"role"` } func (h *StaffHandler) Create(c *gin.Context) { if !requireStaffManager(c) { return } var req CreateStaffReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } username := strings.TrimSpace(req.Username) if utf8.RuneCountInString(username) < 3 || utf8.RuneCountInString(username) > 30 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "用户名需 3-30 个字符"}) return } if len(req.Password) < 6 || len(req.Password) > 64 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"}) return } role := req.Role if role == "" { role = "agent" } if role != "agent" && role != "supervisor" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"}) return } nickname := strings.TrimSpace(req.Nickname) if nickname == "" { nickname = username } if utf8.RuneCountInString(nickname) > 50 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称过长"}) return } tenantID := middleware.GetTenantID(c) seatLimit, err := loadTenantSeatLimit(tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"}) return } used, err := countActiveSeats(tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计坐席失败"}) return } if used >= int64(seatLimit) { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,请升级套餐或增加坐席"}) return } var exists int64 model.DB.Model(&model.User{}).Where("username = ?", username).Count(&exists) if exists > 0 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "用户名已存在"}) return } hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } user := model.User{ TenantID: tenantID, Role: role, Username: username, PasswordHash: string(hash), Nickname: nickname, Status: "offline", } if err := model.DB.Create(&user).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "create_staff", Detail: "创建坐席: " + username + " (" + role + ")", TargetType: "user", TargetID: &user.ID, IP: c.ClientIP(), }) middleware.JSON(c, StaffItem{ ID: user.ID, Username: user.Username, Nickname: user.Nickname, Role: user.Role, Status: user.Status, CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), }) } type UpdateStaffReq struct { Nickname *string `json:"nickname"` Role *string `json:"role"` Status *string `json:"status"` Password *string `json:"password"` } func (h *StaffHandler) Update(c *gin.Context) { if !requireStaffManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") var user model.User if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"}) return } if user.Role == "platform_admin" { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无法操作该账号"}) return } var req UpdateStaffReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } // 不可降级/删除最后一个 admin:若改角色离开 admin,检查剩余 admin updates := map[string]interface{}{} if req.Nickname != nil { n := strings.TrimSpace(*req.Nickname) if n == "" || utf8.RuneCountInString(n) > 50 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "昵称无效"}) return } updates["nickname"] = n } if req.Role != nil { r := *req.Role if r != "agent" && r != "supervisor" && r != "admin" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"}) return } // 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin if user.Role == "admin" && r != "admin" { var adminCnt int64 model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) if adminCnt <= 1 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "至少保留一名启用的租户管理员"}) return } } // 普通管理员不能创建/提升为 admin?允许 admin 设置另一 admin updates["role"] = r } if req.Status != nil { s := *req.Status if s != "online" && s != "offline" && s != "busy" && s != "disabled" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "状态无效"}) return } // 禁用时检查坐席释放;启用时检查配额 if s == "disabled" && user.Status != "disabled" { if user.Role == "admin" { var adminCnt int64 model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ? AND id <> ?", tenantID, "admin", "disabled", user.ID).Count(&adminCnt) if adminCnt < 1 { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能禁用唯一的租户管理员"}) return } } if user.ID == middleware.GetUserID(c) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能禁用当前登录账号"}) return } } if s != "disabled" && user.Status == "disabled" { seatLimit, _ := loadTenantSeatLimit(tenantID) used, _ := countActiveSeats(tenantID) if used >= int64(seatLimit) { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "坐席数已达上限,无法启用"}) return } } updates["status"] = s } if req.Password != nil && *req.Password != "" { if len(*req.Password) < 6 || len(*req.Password) > 64 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "密码需 6-64 位"}) return } hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新密码失败"}) return } updates["password_hash"] = string(hash) } if len(updates) == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if err := model.DB.Model(&user).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } model.DB.First(&user, user.ID) model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "update_staff", Detail: "更新坐席: " + user.Username, TargetType: "user", TargetID: &user.ID, IP: c.ClientIP(), }) item := StaffItem{ ID: user.ID, Username: user.Username, Nickname: user.Nickname, Role: user.Role, Status: user.Status, CreatedAt: user.CreatedAt.Format("2006-01-02 15:04:05"), } if user.LastOnlineAt != nil { item.LastOnlineAt = user.LastOnlineAt.Format("2006-01-02 15:04:05") } middleware.JSON(c, item) } func (h *StaffHandler) Delete(c *gin.Context) { if !requireStaffManager(c) { return } tenantID := middleware.GetTenantID(c) id := c.Param("id") var user model.User if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&user).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "账号不存在"}) return } if user.ID == middleware.GetUserID(c) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能删除当前登录账号"}) return } if user.Role == "admin" { var adminCnt int64 model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt) if adminCnt <= 1 && user.Status != "disabled" { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "不能删除唯一的租户管理员"}) return } } // 软禁用:保留数据关联;物理删除仅对无会话绑定的账号可选——统一用 disabled 更安全 if err := model.DB.Model(&user).Update("status", "disabled").Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "禁用失败"}) return } model.DB.Create(&model.OperationLog{ OperatorID: middleware.GetUserID(c), Action: "disable_staff", Detail: "禁用坐席: " + user.Username, TargetType: "user", TargetID: &user.ID, IP: c.ClientIP(), }) middleware.JSON(c, gin.H{"message": "已禁用"}) }