Files
kefu_cloud/server/internal/handler/staff.go
T
yml2213 ea3901d8b0 实现租户坐席账号管理
新增 /api/staff 列表/创建/更新/禁用,按租户坐席配额校验;系统设置增加坐席账号页支持增改禁与配额展示。
2026-07-15 14:07:06 +08:00

356 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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": "已禁用"})
}