508 lines
16 KiB
Go
508 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"golang.org/x/crypto/bcrypt"
|
|
"kefu-cloud/server/internal/middleware"
|
|
"kefu-cloud/server/internal/model"
|
|
"kefu-cloud/server/internal/ws"
|
|
)
|
|
|
|
type StaffHandler struct{}
|
|
|
|
func NewStaffHandler() *StaffHandler { return &StaffHandler{} }
|
|
|
|
func requireStaffManager(c *gin.Context) bool {
|
|
if middleware.HasPermission(c, "settings.staff") {
|
|
return true
|
|
}
|
|
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"})
|
|
return false
|
|
}
|
|
|
|
// 坐席占用:租户内所有启用账号均计 1 席(含自定义角色)。
|
|
func countActiveSeats(tenantID uint) (int64, error) {
|
|
var n int64
|
|
err := model.DB.Model(&model.User{}).
|
|
Where("tenant_id = ? AND role <> ? AND status <> ?", tenantID, "platform_admin", "disabled").
|
|
Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
func tenantRoleExists(tenantID uint, role string) bool {
|
|
if role == "platform_admin" || strings.TrimSpace(role) == "" {
|
|
return false
|
|
}
|
|
if role == "admin" || role == "supervisor" || role == "agent" {
|
|
return true
|
|
}
|
|
var count int64
|
|
return model.DB.Model(&model.Role{}).
|
|
Where("tenant_id = ? AND code = ?", tenantID, role).
|
|
Count(&count).Error == nil && count > 0
|
|
}
|
|
|
|
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.HasPermission(c, "settings.staff") {
|
|
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 <> ?", tenantID, "platform_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"
|
|
}
|
|
tenantID := middleware.GetTenantID(c)
|
|
if !tenantRoleExists(tenantID, role) {
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
originalRole := user.Role
|
|
originalStatus := user.Status
|
|
|
|
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 !tenantRoleExists(tenantID, r) {
|
|
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)
|
|
if user.Role != originalRole || (originalStatus != "disabled" && user.Status == "disabled") {
|
|
ws.DefaultHub.DisconnectUser(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
|
|
}
|
|
|
|
ws.DefaultHub.DisconnectUser(user.ID, "账号已被停用,请重新登录")
|
|
|
|
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": "已禁用"})
|
|
}
|
|
|
|
// Batch 批量操作:启用/停用/更换角色。
|
|
func (h *StaffHandler) Batch(c *gin.Context) {
|
|
if !requireStaffManager(c) {
|
|
return
|
|
}
|
|
tenantID := middleware.GetTenantID(c)
|
|
var req struct {
|
|
IDs []uint `json:"ids" binding:"required"`
|
|
Action string `json:"action" binding:"required"` // enable | disable | change_role
|
|
NewRole string `json:"new_role"` // change_role 时必填
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
|
return
|
|
}
|
|
if len(req.IDs) == 0 || len(req.IDs) > 100 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择 1-100 个账号"})
|
|
return
|
|
}
|
|
operatorID := middleware.GetUserID(c)
|
|
if req.Action != "enable" && req.Action != "disable" && req.Action != "change_role" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不支持的批量操作"})
|
|
return
|
|
}
|
|
if req.Action == "change_role" && !tenantRoleExists(tenantID, req.NewRole) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"})
|
|
return
|
|
}
|
|
|
|
var users []model.User
|
|
if err := model.DB.Where("id IN ? AND tenant_id = ?", req.IDs, tenantID).Find(&users).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"})
|
|
return
|
|
}
|
|
if len(users) != len(req.IDs) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "部分账号不存在或不属于当前租户"})
|
|
return
|
|
}
|
|
if req.Action == "enable" {
|
|
toEnable := int64(0)
|
|
for _, user := range users {
|
|
if user.Status == "disabled" && user.ID != operatorID {
|
|
toEnable++
|
|
}
|
|
}
|
|
used, err := countActiveSeats(tenantID)
|
|
seatLimit, limitErr := loadTenantSeatLimit(tenantID)
|
|
if err != nil || limitErr != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查坐席配额失败"})
|
|
return
|
|
}
|
|
if used+toEnable > int64(seatLimit) {
|
|
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "批量启用后将超过坐席配额"})
|
|
return
|
|
}
|
|
}
|
|
|
|
processed := 0
|
|
skipped := 0
|
|
for _, u := range users {
|
|
if u.ID == operatorID {
|
|
skipped++
|
|
continue
|
|
}
|
|
switch req.Action {
|
|
case "enable":
|
|
if u.Status == "disabled" {
|
|
if err := model.DB.Model(&u).Updates(map[string]interface{}{"status": "offline"}).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量启用失败"})
|
|
return
|
|
}
|
|
processed++
|
|
model.DB.Create(&model.OperationLog{
|
|
OperatorID: operatorID, Action: "enable_staff", Detail: "启用坐席: " + u.Username,
|
|
TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(),
|
|
})
|
|
}
|
|
case "disable":
|
|
if u.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 && u.Status != "disabled" {
|
|
skipped++
|
|
continue
|
|
}
|
|
}
|
|
if u.Status != "disabled" {
|
|
if err := model.DB.Model(&u).Update("status", "disabled").Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量停用失败"})
|
|
return
|
|
}
|
|
processed++
|
|
ws.DefaultHub.DisconnectUser(u.ID, "账号已被停用,请重新登录")
|
|
model.DB.Create(&model.OperationLog{
|
|
OperatorID: operatorID, Action: "disable_staff", Detail: "禁用坐席: " + u.Username,
|
|
TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(),
|
|
})
|
|
}
|
|
case "change_role":
|
|
if u.Role == "admin" && req.NewRole != "admin" {
|
|
var adminCnt int64
|
|
model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ? AND status <> ?", tenantID, "admin", "disabled").Count(&adminCnt)
|
|
if adminCnt <= 1 {
|
|
skipped++
|
|
continue
|
|
}
|
|
}
|
|
if u.Role != req.NewRole {
|
|
if err := model.DB.Model(&u).Update("role", req.NewRole).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量更换角色失败"})
|
|
return
|
|
}
|
|
processed++
|
|
ws.DefaultHub.DisconnectUser(u.ID, "账号角色已变更,请重新登录")
|
|
model.DB.Create(&model.OperationLog{
|
|
OperatorID: operatorID, Action: "change_staff_role",
|
|
Detail: fmt.Sprintf("变更角色: %s %s → %s", u.Username, u.Role, req.NewRole),
|
|
TargetType: "user", TargetID: &u.ID, IP: c.ClientIP(),
|
|
})
|
|
middleware.InvalidatePermissionCache(tenantID, u.Role)
|
|
}
|
|
}
|
|
}
|
|
|
|
middleware.JSON(c, gin.H{
|
|
"message": fmt.Sprintf("批量操作完成,成功 %d 个,跳过 %d 个", processed, skipped),
|
|
"processed": processed, "skipped": skipped,
|
|
})
|
|
}
|