完善角色权限与数据隔离

This commit is contained in:
yml2213
2026-07-26 14:00:50 +08:00
parent b483cbc26d
commit 16d9ab0e5e
38 changed files with 2847 additions and 292 deletions
+162 -10
View File
@@ -1,6 +1,7 @@
package handler
import (
"fmt"
"net/http"
"strings"
"unicode/utf8"
@@ -9,6 +10,7 @@ import (
"golang.org/x/crypto/bcrypt"
"kefu-cloud/server/internal/middleware"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/ws"
)
type StaffHandler struct{}
@@ -16,22 +18,35 @@ type StaffHandler struct{}
func NewStaffHandler() *StaffHandler { return &StaffHandler{} }
func requireStaffManager(c *gin.Context) bool {
if middleware.HasAnyRole(c, "admin") {
if middleware.HasPermission(c, "settings.staff") {
return true
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可管理坐席账号"})
return false
}
// 坐席占用:租户下 agent / supervisor / admin 均计 1 席(不含 disabled
// 坐席占用:租户内所有启用账号均计 1 席(含自定义角色)。
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").
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 {
@@ -54,13 +69,13 @@ type StaffItem struct {
}
func (h *StaffHandler) List(c *gin.Context) {
if !middleware.HasAnyRole(c, "admin", "supervisor") {
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 IN ?", tenantID, []string{"agent", "supervisor", "admin"}).
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
@@ -116,8 +131,9 @@ func (h *StaffHandler) Create(c *gin.Context) {
if role == "" {
role = "agent"
}
if role != "agent" && role != "supervisor" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅可创建客服或主管账号"})
tenantID := middleware.GetTenantID(c)
if !tenantRoleExists(tenantID, role) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"})
return
}
nickname := strings.TrimSpace(req.Nickname)
@@ -129,7 +145,6 @@ func (h *StaffHandler) Create(c *gin.Context) {
return
}
tenantID := middleware.GetTenantID(c)
seatLimit, err := loadTenantSeatLimit(tenantID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取坐席配额失败"})
@@ -204,6 +219,8 @@ func (h *StaffHandler) Update(c *gin.Context) {
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 {
@@ -223,8 +240,8 @@ func (h *StaffHandler) Update(c *gin.Context) {
}
if req.Role != nil {
r := *req.Role
if r != "agent" && r != "supervisor" && r != "admin" {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色无效"})
if !tenantRoleExists(tenantID, r) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "角色不存在"})
return
}
// 非当前登录用户不能随意把自己改没 admin:禁止把最后一个 admin 改成非 admin
@@ -292,6 +309,9 @@ func (h *StaffHandler) Update(c *gin.Context) {
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),
@@ -343,6 +363,8 @@ func (h *StaffHandler) Delete(c *gin.Context) {
return
}
ws.DefaultHub.DisconnectUser(user.ID, "账号已被停用,请重新登录")
model.DB.Create(&model.OperationLog{
OperatorID: middleware.GetUserID(c),
Action: "disable_staff",
@@ -353,3 +375,133 @@ func (h *StaffHandler) Delete(c *gin.Context) {
})
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,
})
}