完善角色权限与数据隔离
This commit is contained in:
@@ -88,6 +88,19 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// MePermissions 当前登录用户的权限码列表(供前端按钮控制)。
|
||||
func (h *AuthHandler) MePermissions(c *gin.Context) {
|
||||
perms := middleware.GetPermissions(c)
|
||||
codes := make([]string, 0, len(perms))
|
||||
for k := range perms {
|
||||
codes = append(codes, k)
|
||||
}
|
||||
middleware.JSON(c, gin.H{
|
||||
"role": middleware.GetRole(c),
|
||||
"permissions": codes,
|
||||
})
|
||||
}
|
||||
|
||||
type UpdatePresenceReq struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -131,6 +131,10 @@ func durationLabel(duration string) string {
|
||||
|
||||
// Create 创建黑名单(支持从会话拉黑 IP 或设备)。
|
||||
func (h *BlacklistHandler) Create(c *gin.Context) {
|
||||
if !middleware.HasPermission(c, "blacklist.create") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理黑名单"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var req CreateBlacklistReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -163,13 +167,6 @@ func (h *BlacklistHandler) Create(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// 管理员/主管,或当前接待坐席可拉黑
|
||||
if !isTenantManager(c) {
|
||||
if s.AgentID == nil || *s.AgentID != middleware.GetUserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅接待坐席或管理员可拉黑该访客"})
|
||||
return
|
||||
}
|
||||
}
|
||||
session = s
|
||||
sid := s.ID
|
||||
sessionID = &sid
|
||||
@@ -305,6 +302,10 @@ func maskBlacklistValue(kind, value string) string {
|
||||
|
||||
// List 黑名单列表(有效 + 可选含已过期)。
|
||||
func (h *BlacklistHandler) List(c *gin.Context) {
|
||||
if !middleware.HasPermission(c, "blacklist.view") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看黑名单"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
q := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if c.Query("active") != "0" {
|
||||
@@ -323,6 +324,10 @@ func (h *BlacklistHandler) List(c *gin.Context) {
|
||||
|
||||
// Delete 解除黑名单。
|
||||
func (h *BlacklistHandler) Delete(c *gin.Context) {
|
||||
if !middleware.HasPermission(c, "blacklist.delete") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可解除黑名单"})
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
var entry model.BlacklistEntry
|
||||
|
||||
@@ -59,7 +59,7 @@ func toChannelView(ch model.Channel) channelView {
|
||||
}
|
||||
|
||||
func requireTenantAdmin(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin") {
|
||||
if middleware.HasPermission(c, "settings.channel") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可操作"})
|
||||
@@ -74,8 +74,15 @@ func (h *ChannelHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
views := make([]channelView, 0, len(channels))
|
||||
canManage := middleware.HasPermission(c, "settings.channel")
|
||||
for _, ch := range channels {
|
||||
views = append(views, toChannelView(ch))
|
||||
view := toChannelView(ch)
|
||||
if !canManage {
|
||||
view.Config = ""
|
||||
view.ScriptCode = ""
|
||||
view.ChannelKey = ""
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
middleware.JSON(c, views)
|
||||
}
|
||||
|
||||
@@ -14,12 +14,12 @@ type CustomerHandler struct{}
|
||||
func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} }
|
||||
|
||||
func canAccessCustomer(c *gin.Context, customerID uint) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
return true
|
||||
}
|
||||
if middleware.GetRole(c) != "agent" {
|
||||
if !middleware.HasPermission(c, "customer.view") {
|
||||
return false
|
||||
}
|
||||
if middleware.CanAccessAllData(c, "customer") {
|
||||
return true
|
||||
}
|
||||
var count int64
|
||||
model.DB.Model(&model.Session{}).
|
||||
Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)).
|
||||
@@ -38,7 +38,7 @@ func (h *CustomerHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "customer") {
|
||||
assignedCustomers := model.DB.Model(&model.Session{}).
|
||||
Select("customer_id").
|
||||
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
|
||||
@@ -74,13 +74,14 @@ func (h *CustomerHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !canAccessCustomer(c, customer.ID) {
|
||||
middleware.AuditDataAccessDenied(c, "customer", &customer.ID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"})
|
||||
return
|
||||
}
|
||||
|
||||
var sessions []model.Session
|
||||
sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "customer") {
|
||||
sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
sessionQuery.Order("created_at desc").Limit(20).Find(&sessions)
|
||||
@@ -233,6 +234,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if !canAccessCustomer(c, customer.ID) {
|
||||
middleware.AuditDataAccessDenied(c, "customer", &customer.ID)
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"})
|
||||
return
|
||||
}
|
||||
@@ -319,7 +321,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Delete(c *gin.Context) {
|
||||
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
if !middleware.HasAnyPermission(c, "customer.export") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -29,8 +29,7 @@ type CustomerTagHandler struct{}
|
||||
func NewCustomerTagHandler() *CustomerTagHandler { return &CustomerTagHandler{} }
|
||||
|
||||
func requireCustomerTagManager(c *gin.Context) bool {
|
||||
// 管理员维护标签库;主管也可维护,便于运营
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
if middleware.HasPermission(c, "settings.customer_tag") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理客户标签"})
|
||||
|
||||
@@ -21,7 +21,7 @@ func (h *CustomerHandler) Export(c *gin.Context) {
|
||||
source := c.Query("source")
|
||||
|
||||
query := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "chat_history") {
|
||||
assignedCustomers := model.DB.Model(&model.Session{}).
|
||||
Select("customer_id").
|
||||
Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c))
|
||||
@@ -76,7 +76,7 @@ func (h *SessionHandler) Export(c *gin.Context) {
|
||||
to := c.Query("to")
|
||||
|
||||
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "customer") {
|
||||
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
|
||||
}
|
||||
if status != "" {
|
||||
@@ -253,7 +253,8 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
sessions, messages, err := loadStatisticsData(tenantID, r)
|
||||
allData := middleware.CanAccessAllData(c, "statistics")
|
||||
sessions, messages, err := loadStatisticsData(tenantID, middleware.GetUserID(c), allData, r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "导出统计失败"})
|
||||
return
|
||||
@@ -282,7 +283,11 @@ func (h *StatisticsHandler) Export(c *gin.Context) {
|
||||
|
||||
// 坐席绩效
|
||||
var agents []model.User
|
||||
model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents)
|
||||
agentQuery := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if !allData {
|
||||
agentQuery = agentQuery.Where("id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
agentQuery.Find(&agents)
|
||||
type perf struct {
|
||||
Name string
|
||||
Conversations int
|
||||
|
||||
@@ -15,14 +15,18 @@ type KnowledgeHandler struct{}
|
||||
|
||||
func NewKnowledgeHandler() *KnowledgeHandler { return &KnowledgeHandler{} }
|
||||
|
||||
func requireKnowledgeManager(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
func requireKnowledgePermission(c *gin.Context, code string) bool {
|
||||
if middleware.HasPermission(c, code) {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可管理知识库"})
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权执行该知识库操作"})
|
||||
return false
|
||||
}
|
||||
|
||||
func canDeleteKnowledge(c *gin.Context, createdBy uint) bool {
|
||||
return middleware.GetRole(c) == "admin" || (createdBy != 0 && createdBy == middleware.GetUserID(c))
|
||||
}
|
||||
|
||||
func hasKnowledgeCapacity(tenantID uint) (bool, error) {
|
||||
var tenant model.Tenant
|
||||
if err := model.DB.First(&tenant, tenantID).Error; err != nil {
|
||||
@@ -139,7 +143,7 @@ var (
|
||||
|
||||
type catError string
|
||||
|
||||
func errCat(s string) catError { return catError(s) }
|
||||
func errCat(s string) catError { return catError(s) }
|
||||
func (e catError) Error() string { return string(e) }
|
||||
|
||||
func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID uint) bool {
|
||||
@@ -158,7 +162,7 @@ func isCategoryNameTaken(tenantID uint, name string, parentID *uint, excludeID u
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.create") {
|
||||
return
|
||||
}
|
||||
var req CategoryReq
|
||||
@@ -181,9 +185,10 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
category := model.Category{
|
||||
TenantID: tenantID,
|
||||
Name: name,
|
||||
ParentID: req.ParentID,
|
||||
TenantID: tenantID,
|
||||
CreatedBy: middleware.GetUserID(c),
|
||||
Name: name,
|
||||
ParentID: req.ParentID,
|
||||
}
|
||||
if err := model.DB.Create(&category).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"})
|
||||
@@ -193,7 +198,7 @@ func (h *KnowledgeHandler) CreateCategory(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.edit") {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
@@ -248,7 +253,7 @@ func (h *KnowledgeHandler) UpdateCategory(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.delete") {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
@@ -258,6 +263,10 @@ func (h *KnowledgeHandler) DeleteCategory(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "分类不存在"})
|
||||
return
|
||||
}
|
||||
if !canDeleteKnowledge(c, category.CreatedBy) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "主管仅可删除自己创建的分类"})
|
||||
return
|
||||
}
|
||||
var childCnt int64
|
||||
model.DB.Model(&model.Category{}).Where("parent_id = ? AND tenant_id = ?", category.ID, tenantID).Count(&childCnt)
|
||||
if childCnt > 0 {
|
||||
@@ -364,7 +373,7 @@ func (h *KnowledgeHandler) ListEntries(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.create") {
|
||||
return
|
||||
}
|
||||
var entry model.KnowledgeEntry
|
||||
@@ -373,6 +382,11 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
entry.TenantID = middleware.GetTenantID(c)
|
||||
entry.CreatedBy = middleware.GetUserID(c)
|
||||
if entry.Status == "published" && !middleware.HasPermission(c, "knowledge.publish") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权直接发布知识条目"})
|
||||
return
|
||||
}
|
||||
available, err := hasKnowledgeCapacity(entry.TenantID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "校验知识库容量失败"})
|
||||
@@ -397,7 +411,7 @@ func (h *KnowledgeHandler) CreateEntry(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.edit") {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
@@ -432,6 +446,10 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"})
|
||||
return
|
||||
}
|
||||
if status, exists := updates["status"]; exists && status != entry.Status && !middleware.HasPermission(c, "knowledge.publish") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权发布或下架知识条目"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.DB.Model(&entry).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"})
|
||||
@@ -442,13 +460,22 @@ func (h *KnowledgeHandler) UpdateEntry(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *KnowledgeHandler) DeleteEntry(c *gin.Context) {
|
||||
if !requireKnowledgeManager(c) {
|
||||
if !requireKnowledgePermission(c, "knowledge.delete") {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
result := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&model.KnowledgeEntry{})
|
||||
var entry model.KnowledgeEntry
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&entry).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"})
|
||||
return
|
||||
}
|
||||
if !canDeleteKnowledge(c, entry.CreatedBy) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "主管仅可删除自己创建的知识条目"})
|
||||
return
|
||||
}
|
||||
result := model.DB.Delete(&entry)
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "条目不存在"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"kefu-cloud/server/internal/middleware"
|
||||
"kefu-cloud/server/internal/model"
|
||||
)
|
||||
|
||||
type PermissionHandler struct{}
|
||||
|
||||
func NewPermissionHandler() *PermissionHandler { return &PermissionHandler{} }
|
||||
|
||||
func ensurePermissionData(tenantID uint) error {
|
||||
if err := model.EnsurePermissions(); err != nil {
|
||||
return err
|
||||
}
|
||||
return model.EnsureBuiltinRoles(tenantID)
|
||||
}
|
||||
|
||||
// ListPermissions 获取所有系统权限码(全租户共享,供权限配置页使用)。
|
||||
func (h *PermissionHandler) ListPermissions(c *gin.Context) {
|
||||
if err := ensurePermissionData(middleware.GetTenantID(c)); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "初始化权限数据失败"})
|
||||
return
|
||||
}
|
||||
var permissions []model.Permission
|
||||
if err := model.DB.Order("sort_order asc").Find(&permissions).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, permissions)
|
||||
}
|
||||
|
||||
// ListRoles 获取当前租户的角色列表(含成员数、权限数)。
|
||||
func (h *PermissionHandler) ListRoles(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if err := ensurePermissionData(tenantID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "初始化权限数据失败"})
|
||||
return
|
||||
}
|
||||
var roles []model.Role
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Order("id asc").Find(&roles).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询失败"})
|
||||
return
|
||||
}
|
||||
|
||||
type roleWithStats struct {
|
||||
model.Role
|
||||
MemberCount int64 `json:"member_count"`
|
||||
PermCount int64 `json:"perm_count"`
|
||||
}
|
||||
result := make([]roleWithStats, len(roles))
|
||||
for i, role := range roles {
|
||||
result[i].Role = role
|
||||
if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&result[i].MemberCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色成员失败"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Model(&model.RolePermission{}).Where("role_id = ?", role.ID).Count(&result[i].PermCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色权限失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
middleware.JSON(c, result)
|
||||
}
|
||||
|
||||
type roleAccessSnapshot struct {
|
||||
Permissions []string `json:"permissions"`
|
||||
DataScopes map[string]string `json:"data_scopes"`
|
||||
}
|
||||
|
||||
func loadRoleAccess(tx *gorm.DB, role model.Role) (roleAccessSnapshot, error) {
|
||||
snapshot := roleAccessSnapshot{Permissions: []string{}, DataScopes: model.DefaultRoleDataScopes(role.Code)}
|
||||
if err := tx.Table("role_permissions rp").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("rp.role_id = ?", role.ID).
|
||||
Order("p.sort_order asc").
|
||||
Pluck("p.code", &snapshot.Permissions).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
var scopes []model.RoleDataScope
|
||||
if err := tx.Where("role_id = ?", role.ID).Find(&scopes).Error; err != nil {
|
||||
return snapshot, err
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
snapshot.DataScopes[scope.Module] = scope.Scope
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
// GetRole 获取角色详情(含权限码与数据范围)。
|
||||
func (h *PermissionHandler) GetRole(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var role model.Role
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"})
|
||||
return
|
||||
}
|
||||
access, err := loadRoleAccess(model.DB, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询角色权限失败"})
|
||||
return
|
||||
}
|
||||
var memberCount int64
|
||||
if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&memberCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "统计角色成员失败"})
|
||||
return
|
||||
}
|
||||
middleware.JSON(c, gin.H{
|
||||
"role": role, "permissions": access.Permissions,
|
||||
"data_scopes": access.DataScopes, "member_count": memberCount,
|
||||
})
|
||||
}
|
||||
|
||||
type saveRoleReq struct {
|
||||
Name *string `json:"name"`
|
||||
Desc *string `json:"desc"`
|
||||
Permissions []string `json:"permissions"`
|
||||
DataScopes map[string]string `json:"data_scopes"`
|
||||
}
|
||||
|
||||
func validateRoleName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || len([]rune(name)) > 50 {
|
||||
return "", errors.New("角色名称需 1-50 个字符")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validateRoleAccess(tx *gorm.DB, permissions []string, dataScopes map[string]string) ([]model.Permission, error) {
|
||||
uniqueCodes := make(map[string]struct{}, len(permissions))
|
||||
for _, code := range permissions {
|
||||
code = strings.TrimSpace(code)
|
||||
if code != "" {
|
||||
uniqueCodes[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
codes := make([]string, 0, len(uniqueCodes))
|
||||
for code := range uniqueCodes {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
sort.Strings(codes)
|
||||
var records []model.Permission
|
||||
if len(codes) > 0 {
|
||||
if err := tx.Where("code IN ?", codes).Order("sort_order asc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) != len(codes) {
|
||||
return nil, errors.New("包含无效权限码")
|
||||
}
|
||||
}
|
||||
|
||||
selected := make(map[string]bool, len(records))
|
||||
moduleViews := make(map[string][]string)
|
||||
for _, permission := range records {
|
||||
selected[permission.Code] = true
|
||||
}
|
||||
var allPermissions []model.Permission
|
||||
if err := tx.Order("sort_order asc").Find(&allPermissions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, permission := range allPermissions {
|
||||
if permission.Category == "view" {
|
||||
moduleViews[permission.Module] = append(moduleViews[permission.Module], permission.Code)
|
||||
}
|
||||
}
|
||||
for _, permission := range records {
|
||||
if permission.Category != "operate" || len(moduleViews[permission.Module]) == 0 {
|
||||
continue
|
||||
}
|
||||
hasView := false
|
||||
for _, viewCode := range moduleViews[permission.Module] {
|
||||
hasView = hasView || selected[viewCode]
|
||||
}
|
||||
if !hasView {
|
||||
return nil, errors.New(permission.Module + "的操作权限必须同时启用查看权限")
|
||||
}
|
||||
}
|
||||
|
||||
allowedModules := make(map[string]bool)
|
||||
for _, module := range model.DataScopeModules() {
|
||||
allowedModules[module] = true
|
||||
}
|
||||
for module, scope := range dataScopes {
|
||||
if !allowedModules[module] || (scope != model.DataScopeAll && scope != model.DataScopeSelf) {
|
||||
return nil, errors.New("包含无效数据范围")
|
||||
}
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func saveRoleAccess(tx *gorm.DB, role model.Role, permissions []string, dataScopes map[string]string) error {
|
||||
records, err := validateRoleAccess(tx, permissions, dataScopes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, permission := range records {
|
||||
if err := tx.Create(&model.RolePermission{RoleID: role.ID, PermissionID: permission.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for module, scope := range dataScopes {
|
||||
item := model.RoleDataScope{RoleID: role.ID, Module: module}
|
||||
if err := tx.Where(item).Assign(model.RoleDataScope{Scope: scope}).FirstOrCreate(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// UpdatedAt 同时作为前端权限缓存版本,关联表变更时也必须推进。
|
||||
return tx.Model(&role).UpdateColumn("updated_at", time.Now()).Error
|
||||
}
|
||||
|
||||
func newCustomRoleCode() (string, error) {
|
||||
random := make([]byte, 8)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "custom_" + hex.EncodeToString(random), nil
|
||||
}
|
||||
|
||||
// CreateRole 创建自定义角色。
|
||||
func (h *PermissionHandler) CreateRole(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var req saveRoleReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Name == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
name, err := validateRoleName(*req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
var duplicate int64
|
||||
if err := model.DB.Model(&model.Role{}).Where("tenant_id = ? AND name = ?", tenantID, name).Count(&duplicate).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查角色名称失败"})
|
||||
return
|
||||
}
|
||||
if duplicate > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "角色名称已被占用"})
|
||||
return
|
||||
}
|
||||
code, err := newCustomRoleCode()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "生成角色标识失败"})
|
||||
return
|
||||
}
|
||||
desc := ""
|
||||
if req.Desc != nil {
|
||||
desc = strings.TrimSpace(*req.Desc)
|
||||
}
|
||||
role := model.Role{TenantID: tenantID, Name: name, Code: code, Type: "custom", Desc: desc}
|
||||
dataScopes := req.DataScopes
|
||||
if dataScopes == nil {
|
||||
dataScopes = model.DefaultRoleDataScopes(role.Code)
|
||||
}
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return saveRoleAccess(tx, role, req.Permissions, dataScopes)
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
middleware.InvalidatePermissionCache(tenantID, role.Code)
|
||||
logPermissionChange(c, "create_role", role, roleAccessSnapshot{}, roleAccessSnapshot{Permissions: req.Permissions, DataScopes: dataScopes})
|
||||
middleware.JSON(c, role)
|
||||
}
|
||||
|
||||
// UpdateRole 更新自定义角色信息、权限与数据范围,角色标识保持不变。
|
||||
func (h *PermissionHandler) UpdateRole(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var role model.Role
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"})
|
||||
return
|
||||
}
|
||||
if role.Type == "builtin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "内置角色请使用专用配置接口"})
|
||||
return
|
||||
}
|
||||
var req saveRoleReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
|
||||
return
|
||||
}
|
||||
before, err := loadRoleAccess(model.DB, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取原权限失败"})
|
||||
return
|
||||
}
|
||||
if req.Permissions == nil {
|
||||
req.Permissions = before.Permissions
|
||||
}
|
||||
if req.DataScopes == nil {
|
||||
req.DataScopes = before.DataScopes
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
name, err := validateRoleName(*req.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
var duplicate int64
|
||||
model.DB.Model(&model.Role{}).Where("tenant_id = ? AND name = ? AND id <> ?", tenantID, name, role.ID).Count(&duplicate)
|
||||
if duplicate > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "角色名称已被占用"})
|
||||
return
|
||||
}
|
||||
updates["name"] = name
|
||||
}
|
||||
if req.Desc != nil {
|
||||
updates["desc"] = strings.TrimSpace(*req.Desc)
|
||||
}
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if len(updates) > 0 {
|
||||
if err := tx.Model(&role).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return saveRoleAccess(tx, role, req.Permissions, req.DataScopes)
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
model.DB.First(&role, role.ID)
|
||||
after, _ := loadRoleAccess(model.DB, role)
|
||||
middleware.InvalidatePermissionCache(tenantID, role.Code)
|
||||
logPermissionChange(c, "update_role", role, before, after)
|
||||
middleware.JSON(c, role)
|
||||
}
|
||||
|
||||
// UpdateBuiltinRole 更新内置角色权限与数据范围,不允许清空权限或修改标识。
|
||||
func (h *PermissionHandler) UpdateBuiltinRole(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var role model.Role
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"})
|
||||
return
|
||||
}
|
||||
if role.Type != "builtin" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅内置角色可使用此接口"})
|
||||
return
|
||||
}
|
||||
var req saveRoleReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Permissions) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "内置角色至少需要一项权限"})
|
||||
return
|
||||
}
|
||||
before, err := loadRoleAccess(model.DB, role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "读取原权限失败"})
|
||||
return
|
||||
}
|
||||
if req.DataScopes == nil {
|
||||
req.DataScopes = before.DataScopes
|
||||
}
|
||||
if role.Code == "admin" {
|
||||
required := map[string]bool{
|
||||
"permission.view": false, "permission.create_role": false,
|
||||
"permission.delete_role": false, "permission.assign_role": false,
|
||||
}
|
||||
for _, code := range req.Permissions {
|
||||
if _, exists := required[code]; exists {
|
||||
required[code] = true
|
||||
}
|
||||
}
|
||||
for _, enabled := range required {
|
||||
if !enabled {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "管理员必须保留全部权限控制权限"})
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, module := range model.DataScopeModules() {
|
||||
req.DataScopes[module] = model.DataScopeAll
|
||||
}
|
||||
}
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if req.Desc != nil {
|
||||
if err := tx.Model(&role).Update("desc", strings.TrimSpace(*req.Desc)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return saveRoleAccess(tx, role, req.Permissions, req.DataScopes)
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
model.DB.First(&role, role.ID)
|
||||
after, _ := loadRoleAccess(model.DB, role)
|
||||
middleware.InvalidatePermissionCache(tenantID, role.Code)
|
||||
logPermissionChange(c, "update_builtin_role", role, before, after)
|
||||
middleware.JSON(c, role)
|
||||
}
|
||||
|
||||
// DeleteRole 删除无成员绑定的自定义角色。
|
||||
func (h *PermissionHandler) DeleteRole(c *gin.Context) {
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
var role model.Role
|
||||
if err := model.DB.Where("id = ? AND tenant_id = ?", c.Param("id"), tenantID).First(&role).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "角色不存在"})
|
||||
return
|
||||
}
|
||||
if role.Type == "builtin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "内置角色不可删除"})
|
||||
return
|
||||
}
|
||||
var memberCount int64
|
||||
if err := model.DB.Model(&model.User{}).Where("tenant_id = ? AND role = ?", tenantID, role.Code).Count(&memberCount).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "检查角色成员失败"})
|
||||
return
|
||||
}
|
||||
if memberCount > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "请先为该角色下的成员更换角色", "member_count": memberCount})
|
||||
return
|
||||
}
|
||||
before, _ := loadRoleAccess(model.DB, role)
|
||||
if err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("role_id = ?", role.ID).Delete(&model.RolePermission{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("role_id = ?", role.ID).Delete(&model.RoleDataScope{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&role).Error
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "删除失败"})
|
||||
return
|
||||
}
|
||||
middleware.InvalidatePermissionCache(tenantID, role.Code)
|
||||
logPermissionChange(c, "delete_role", role, before, roleAccessSnapshot{})
|
||||
middleware.JSON(c, gin.H{"message": "已删除"})
|
||||
}
|
||||
|
||||
func logPermissionChange(c *gin.Context, action string, role model.Role, before, after roleAccessSnapshot) {
|
||||
detail, _ := json.Marshal(gin.H{
|
||||
"role": role.Name, "code": role.Code, "before": before, "after": after,
|
||||
})
|
||||
model.DB.Create(&model.OperationLog{
|
||||
OperatorID: middleware.GetUserID(c), Action: action, Detail: string(detail),
|
||||
TargetType: "role", TargetID: &role.ID, IP: c.ClientIP(),
|
||||
})
|
||||
}
|
||||
@@ -35,8 +35,8 @@ type QuickReplyHandler struct{}
|
||||
|
||||
func NewQuickReplyHandler() *QuickReplyHandler { return &QuickReplyHandler{} }
|
||||
|
||||
func requireTeamQuickReplyManager(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
func requireTeamQuickReplyPermission(c *gin.Context, code string) bool {
|
||||
if middleware.HasPermission(c, code) {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅管理员或主管可管理团队快捷回复"})
|
||||
@@ -81,7 +81,7 @@ func loadQuickReply(c *gin.Context, id uint) (*model.QuickReply, bool) {
|
||||
func canEditQuickReply(c *gin.Context, item *model.QuickReply) bool {
|
||||
uid := middleware.GetUserID(c)
|
||||
if item.Scope == quickReplyScopeTeam {
|
||||
return middleware.HasAnyRole(c, "admin", "supervisor")
|
||||
return middleware.HasPermission(c, "quick_reply.team_edit")
|
||||
}
|
||||
return item.OwnerUserID != nil && *item.OwnerUserID == uid
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func (h *QuickReplyHandler) List(c *gin.Context) {
|
||||
case quickReplyScopeTeam:
|
||||
db = db.Where("scope = ?", quickReplyScopeTeam)
|
||||
// 非管理端:工作台只看已发布;管理页可传 status
|
||||
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
if !middleware.HasAnyPermission(c, "quick_reply.team_create", "quick_reply.team_edit") {
|
||||
db = db.Where("status = ?", quickReplyStatusPub)
|
||||
} else if status != "" {
|
||||
db = db.Where("status = ?", status)
|
||||
@@ -316,7 +316,7 @@ func (h *QuickReplyHandler) Create(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
|
||||
return
|
||||
}
|
||||
if scope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) {
|
||||
if scope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -441,7 +441,7 @@ func (h *QuickReplyHandler) Delete(c *gin.Context) {
|
||||
|
||||
// Publish POST /api/quick-replies/:id/publish 团队:暂存 → 发布同步
|
||||
func (h *QuickReplyHandler) Publish(c *gin.Context) {
|
||||
if !requireTeamQuickReplyManager(c) {
|
||||
if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -463,7 +463,7 @@ func (h *QuickReplyHandler) Publish(c *gin.Context) {
|
||||
|
||||
// Unpublish POST /api/quick-replies/:id/unpublish
|
||||
func (h *QuickReplyHandler) Unpublish(c *gin.Context) {
|
||||
if !requireTeamQuickReplyManager(c) {
|
||||
if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
|
||||
return
|
||||
}
|
||||
id64, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -494,7 +494,7 @@ func (h *QuickReplyHandler) Use(c *gin.Context) {
|
||||
uid := middleware.GetUserID(c)
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
if item.Scope == quickReplyScopeTeam && item.Status != quickReplyStatusPub {
|
||||
if !middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
if !middleware.HasAnyPermission(c, "quick_reply.team_create", "quick_reply.team_edit") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "该话术尚未发布"})
|
||||
return
|
||||
}
|
||||
@@ -590,7 +590,7 @@ func (h *QuickReplyHandler) Export(c *gin.Context) {
|
||||
db := model.DB.Where("tenant_id = ?", tenantID)
|
||||
switch scope {
|
||||
case quickReplyScopeTeam:
|
||||
if !requireTeamQuickReplyManager(c) {
|
||||
if !requireTeamQuickReplyPermission(c, "quick_reply.team_edit") {
|
||||
return
|
||||
}
|
||||
db = db.Where("scope = ?", quickReplyScopeTeam)
|
||||
@@ -646,14 +646,15 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "scope 无效"})
|
||||
return
|
||||
}
|
||||
if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyManager(c) {
|
||||
if defaultScope == quickReplyScopeTeam && !requireTeamQuickReplyPermission(c, "quick_reply.team_create") {
|
||||
return
|
||||
}
|
||||
onConflict := strings.TrimSpace(c.DefaultPostForm("on_conflict", "skip"))
|
||||
if onConflict != "skip" && onConflict != "overwrite" {
|
||||
onConflict = "skip"
|
||||
}
|
||||
canManageTeam := middleware.HasAnyRole(c, "admin", "supervisor")
|
||||
canCreateTeam := middleware.HasPermission(c, "quick_reply.team_create")
|
||||
canEditTeam := middleware.HasPermission(c, "quick_reply.team_edit")
|
||||
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -721,7 +722,7 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
|
||||
rowScope = parsed
|
||||
}
|
||||
|
||||
if rowScope == quickReplyScopeTeam && !canManageTeam {
|
||||
if rowScope == quickReplyScopeTeam && !canCreateTeam {
|
||||
skipped++
|
||||
errors = append(errors, fmt.Sprintf("第 %d 行:无权限导入团队快捷回复", lineNo))
|
||||
continue
|
||||
@@ -775,6 +776,11 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
if rowScope == quickReplyScopeTeam && !canEditTeam {
|
||||
skipped++
|
||||
errors = append(errors, fmt.Sprintf("第 %d 行:无权限覆盖团队快捷回复", lineNo))
|
||||
continue
|
||||
}
|
||||
if err := model.DB.Model(&existing).Updates(map[string]interface{}{
|
||||
"title": title, "content": content, "shortcut": shortcut,
|
||||
}).Error; err != nil {
|
||||
@@ -813,4 +819,3 @@ func (h *QuickReplyHandler) Import(c *gin.Context) {
|
||||
"errors": errors,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
wsHandler := NewWsHandler()
|
||||
widget := NewWidgetHandler()
|
||||
upload := NewUploadHandler(store, storageCfg)
|
||||
perm := NewPermissionHandler()
|
||||
|
||||
SetImagePublicBase(storageCfg.PublicBase)
|
||||
// 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖)
|
||||
@@ -53,105 +54,123 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
|
||||
{
|
||||
// 当前用户
|
||||
authRequired.GET("/me", auth.Me)
|
||||
authRequired.GET("/me/permissions", auth.MePermissions)
|
||||
authRequired.PUT("/me/status", auth.UpdatePresence)
|
||||
|
||||
// WebSocket
|
||||
authRequired.GET("/ws", wsHandler.Connect)
|
||||
|
||||
// 上传
|
||||
authRequired.POST("/uploads", upload.UploadImage)
|
||||
authRequired.POST("/uploads", middleware.RequirePermission("session.reply"), upload.UploadImage)
|
||||
|
||||
// 会话管理
|
||||
authRequired.GET("/agents/available", session.ListAvailableAgents)
|
||||
authRequired.GET("/agents/available", middleware.RequirePermission("session.transfer", "chat_history.view"), session.ListAvailableAgents)
|
||||
sessions := authRequired.Group("/sessions")
|
||||
sessions.GET("", session.List)
|
||||
sessions.GET("/export", session.Export)
|
||||
sessions.GET("/:id", session.Get)
|
||||
sessions.POST("", session.Create)
|
||||
sessions.POST("/batch-archive", session.BatchArchive)
|
||||
sessions.POST("/:id/assign", session.Assign)
|
||||
sessions.POST("/:id/transfer", session.Transfer)
|
||||
sessions.POST("/:id/end", session.End)
|
||||
sessions.POST("/:id/archive", session.Archive)
|
||||
sessions.PUT("/:id/priority", session.UpdatePriority)
|
||||
sessions.GET("/:id/messages", session.ListMessages)
|
||||
sessions.POST("/:id/messages", session.SendMessage)
|
||||
sessions.POST("/:id/read", session.MarkRead)
|
||||
sessions.POST("/:id/notes", session.AddNote)
|
||||
sessions.GET("", middleware.RequirePermission("session.view", "chat_history.view"), session.List)
|
||||
sessions.GET("/export", middleware.RequirePermission("chat_history.export"), session.Export)
|
||||
sessions.GET("/:id", middleware.RequirePermission("session.view", "chat_history.detail"), session.Get)
|
||||
sessions.POST("", middleware.RequirePermission("session.view"), session.Create)
|
||||
sessions.POST("/batch-archive", middleware.RequirePermission("chat_history.batch_archive"), session.BatchArchive)
|
||||
sessions.POST("/:id/assign", middleware.RequirePermission("session.view"), session.Assign)
|
||||
sessions.POST("/:id/transfer", middleware.RequirePermission("session.transfer"), session.Transfer)
|
||||
sessions.POST("/:id/end", middleware.RequirePermission("session.end"), session.End)
|
||||
sessions.POST("/:id/archive", middleware.RequirePermission("chat_history.batch_archive"), session.Archive)
|
||||
sessions.PUT("/:id/priority", middleware.RequirePermission("session.priority"), session.UpdatePriority)
|
||||
sessions.GET("/:id/messages", middleware.RequirePermission("session.view", "chat_history.detail"), session.ListMessages)
|
||||
sessions.POST("/:id/messages", middleware.RequirePermission("session.reply"), session.SendMessage)
|
||||
sessions.POST("/:id/read", middleware.RequirePermission("session.view"), session.MarkRead)
|
||||
sessions.POST("/:id/notes", middleware.RequirePermission("session.note"), session.AddNote)
|
||||
|
||||
// 客户管理
|
||||
customers := authRequired.Group("/customers")
|
||||
customers.GET("", customer.List)
|
||||
customers.GET("/export", customer.Export)
|
||||
customers.GET("/:id", customer.Get)
|
||||
customers.POST("", customer.Create)
|
||||
customers.PUT("/:id", customer.Update)
|
||||
customers.DELETE("/:id", customer.Delete)
|
||||
customers.GET("", middleware.RequirePermission("customer.view"), customer.List)
|
||||
customers.GET("/export", middleware.RequirePermission("customer.export"), customer.Export)
|
||||
customers.GET("/:id", middleware.RequirePermission("customer.view"), customer.Get)
|
||||
customers.POST("", middleware.RequirePermission("customer.create"), customer.Create)
|
||||
customers.PUT("/:id", middleware.RequirePermission("customer.edit"), customer.Update)
|
||||
customers.DELETE("/:id", middleware.RequirePermission("customer.export"), customer.Delete)
|
||||
|
||||
// 黑名单(拉黑 IP / 设备)
|
||||
bl := authRequired.Group("/blacklist")
|
||||
bl.GET("", blacklist.List)
|
||||
bl.POST("", blacklist.Create)
|
||||
bl.DELETE("/:id", blacklist.Delete)
|
||||
bl.GET("", middleware.RequirePermission("blacklist.view"), blacklist.List)
|
||||
bl.POST("", middleware.RequirePermission("blacklist.create"), blacklist.Create)
|
||||
bl.DELETE("/:id", middleware.RequirePermission("blacklist.delete"), blacklist.Delete)
|
||||
|
||||
// 客户标签库(管理员维护,全员可读可选)
|
||||
ctags := authRequired.Group("/customer-tags")
|
||||
ctags.GET("", customerTag.List)
|
||||
ctags.POST("", customerTag.Create)
|
||||
ctags.PUT("/:id", customerTag.Update)
|
||||
ctags.DELETE("/:id", customerTag.Delete)
|
||||
ctags.GET("", middleware.RequirePermission("customer.view", "settings.customer_tag"), customerTag.List)
|
||||
ctags.POST("", middleware.RequirePermission("settings.customer_tag"), customerTag.Create)
|
||||
ctags.PUT("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Update)
|
||||
ctags.DELETE("/:id", middleware.RequirePermission("settings.customer_tag"), customerTag.Delete)
|
||||
|
||||
// 知识库
|
||||
kb := authRequired.Group("/knowledge")
|
||||
kb.GET("/categories", knowledge.ListCategories)
|
||||
kb.POST("/categories", knowledge.CreateCategory)
|
||||
kb.PUT("/categories/:id", knowledge.UpdateCategory)
|
||||
kb.DELETE("/categories/:id", knowledge.DeleteCategory)
|
||||
kb.GET("/entries", knowledge.ListEntries)
|
||||
kb.POST("/entries", knowledge.CreateEntry)
|
||||
kb.PUT("/entries/:id", knowledge.UpdateEntry)
|
||||
kb.DELETE("/entries/:id", knowledge.DeleteEntry)
|
||||
kb.GET("/categories", middleware.RequirePermission("knowledge.view"), knowledge.ListCategories)
|
||||
kb.POST("/categories", middleware.RequirePermission("knowledge.create"), knowledge.CreateCategory)
|
||||
kb.PUT("/categories/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateCategory)
|
||||
kb.DELETE("/categories/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteCategory)
|
||||
kb.GET("/entries", middleware.RequirePermission("knowledge.view"), knowledge.ListEntries)
|
||||
kb.POST("/entries", middleware.RequirePermission("knowledge.create"), knowledge.CreateEntry)
|
||||
kb.PUT("/entries/:id", middleware.RequirePermission("knowledge.edit"), knowledge.UpdateEntry)
|
||||
kb.DELETE("/entries/:id", middleware.RequirePermission("knowledge.delete"), knowledge.DeleteEntry)
|
||||
|
||||
// 快捷回复(团队 / 个人,与知识库独立)
|
||||
qr := authRequired.Group("/quick-replies")
|
||||
qr.GET("", quickReply.List)
|
||||
qr.GET("/suggest", quickReply.Suggest)
|
||||
qr.GET("/export", quickReply.Export)
|
||||
qr.GET("/import-template", quickReply.ImportTemplate)
|
||||
qr.POST("/import", quickReply.Import)
|
||||
qr.POST("", quickReply.Create)
|
||||
qr.PUT("/:id", quickReply.Update)
|
||||
qr.DELETE("/:id", quickReply.Delete)
|
||||
qr.POST("/:id/publish", quickReply.Publish)
|
||||
qr.POST("/:id/unpublish", quickReply.Unpublish)
|
||||
qr.POST("/:id/use", quickReply.Use)
|
||||
qr.GET("", middleware.RequirePermission("quick_reply.view"), quickReply.List)
|
||||
qr.GET("/suggest", middleware.RequirePermission("quick_reply.view"), quickReply.Suggest)
|
||||
qr.GET("/export", middleware.RequirePermission("quick_reply.view"), quickReply.Export)
|
||||
qr.GET("/import-template", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.ImportTemplate)
|
||||
qr.POST("/import", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create", "quick_reply.team_edit"), quickReply.Import)
|
||||
qr.POST("", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_create"), quickReply.Create)
|
||||
qr.PUT("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Update)
|
||||
qr.DELETE("/:id", middleware.RequirePermission("quick_reply.personal_manage", "quick_reply.team_edit"), quickReply.Delete)
|
||||
qr.POST("/:id/publish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Publish)
|
||||
qr.POST("/:id/unpublish", middleware.RequirePermission("quick_reply.team_edit"), quickReply.Unpublish)
|
||||
qr.POST("/:id/use", middleware.RequirePermission("quick_reply.view"), quickReply.Use)
|
||||
|
||||
// 渠道设置(租户级)
|
||||
channels := authRequired.Group("/channels")
|
||||
channels.GET("", channel.List)
|
||||
channels.POST("", channel.Create)
|
||||
channels.PUT("/:id", channel.Update)
|
||||
channels.POST("", middleware.RequirePermission("settings.channel"), channel.Create)
|
||||
channels.PUT("/:id", middleware.RequirePermission("settings.channel"), channel.Update)
|
||||
|
||||
// 租户系统设置
|
||||
settingsGroup := authRequired.Group("/settings")
|
||||
settingsGroup.GET("", settings.Get)
|
||||
settingsGroup.PUT("", settings.Update)
|
||||
settingsGroup.GET("", middleware.RequirePermission(
|
||||
"settings.basic", "settings.assign_rule", "settings.auto_reply", "settings.worktime", "settings.notification",
|
||||
), settings.Get)
|
||||
settingsGroup.PUT("", middleware.RequirePermission("settings.basic"), settings.Update)
|
||||
|
||||
// 坐席账号(租户内)
|
||||
staffGroup := authRequired.Group("/staff")
|
||||
staffGroup.Use(middleware.AdminRequired(), middleware.RequirePermission("settings.staff"))
|
||||
staffGroup.GET("", staff.List)
|
||||
staffGroup.POST("", staff.Create)
|
||||
staffGroup.POST("/batch", staff.Batch)
|
||||
staffGroup.PUT("/:id", staff.Update)
|
||||
staffGroup.DELETE("/:id", staff.Delete)
|
||||
|
||||
// 权限管理(管理员专属)
|
||||
rolesGroup := authRequired.Group("/roles")
|
||||
rolesGroup.Use(middleware.AdminRequired())
|
||||
{
|
||||
rolesGroup.GET("", middleware.RequirePermission("permission.view"), perm.ListRoles)
|
||||
rolesGroup.GET("/permissions", middleware.RequirePermission("permission.view"), perm.ListPermissions)
|
||||
rolesGroup.GET("/:id", middleware.RequirePermission("permission.view"), perm.GetRole)
|
||||
rolesGroup.POST("", middleware.RequirePermission("permission.create_role"), perm.CreateRole)
|
||||
rolesGroup.PUT("/:id", middleware.RequirePermission("permission.create_role"), perm.UpdateRole)
|
||||
rolesGroup.PUT("/:id/builtin", middleware.RequirePermission("permission.create_role"), perm.UpdateBuiltinRole)
|
||||
rolesGroup.DELETE("/:id", middleware.RequirePermission("permission.delete_role"), perm.DeleteRole)
|
||||
}
|
||||
|
||||
// 统计
|
||||
statistics := authRequired.Group("/statistics")
|
||||
statistics.GET("/kpi", stats.KPIs)
|
||||
statistics.GET("/trend", stats.SessionTrend)
|
||||
statistics.GET("/response-distribution", stats.ResponseDistribution)
|
||||
statistics.GET("/performance", stats.AgentPerformance)
|
||||
statistics.GET("/channels", stats.ChannelDistribution)
|
||||
statistics.GET("/export", stats.Export)
|
||||
statistics.GET("/kpi", middleware.RequirePermission("statistics.view"), stats.KPIs)
|
||||
statistics.GET("/trend", middleware.RequirePermission("statistics.view"), stats.SessionTrend)
|
||||
statistics.GET("/response-distribution", middleware.RequirePermission("statistics.view"), stats.ResponseDistribution)
|
||||
statistics.GET("/performance", middleware.RequirePermission("statistics.performance"), stats.AgentPerformance)
|
||||
statistics.GET("/channels", middleware.RequirePermission("statistics.view"), stats.ChannelDistribution)
|
||||
statistics.GET("/export", middleware.RequirePermission("statistics.export"), stats.Export)
|
||||
|
||||
// 管理端接口(需要管理员权限)
|
||||
adminGroup := authRequired.Group("/admin")
|
||||
|
||||
@@ -74,7 +74,6 @@ func multipartImageRequest(t *testing.T, method, target string, fileField string
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
func createTenant(t *testing.T, name, status string) model.Tenant {
|
||||
t.Helper()
|
||||
tenant := model.Tenant{Name: name, Status: status, ExpireAt: time.Now().AddDate(1, 0, 0)}
|
||||
@@ -381,6 +380,7 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
tenant := createTenant(t, "工作台租户", "normal")
|
||||
agentOne := createUser(t, tenant.ID, "workbench-agent-one", "agent")
|
||||
agentTwo := createUser(t, tenant.ID, "workbench-agent-two", "agent")
|
||||
supervisor := createUser(t, tenant.ID, "workbench-supervisor", "supervisor")
|
||||
customer := model.Customer{TenantID: tenant.ID, Name: "工作台客户", Source: "网页"}
|
||||
if err := model.DB.Create(&customer).Error; err != nil {
|
||||
t.Fatalf("创建工作台客户失败: %v", err)
|
||||
@@ -446,8 +446,14 @@ func TestWorkbenchSessionLifecycleUnreadNotesTransferAndImage(t *testing.T) {
|
||||
transferRecorder := httptest.NewRecorder()
|
||||
transferBody := []byte(fmt.Sprintf(`{"agent_id":%d}`, agentTwo.ID))
|
||||
router.ServeHTTP(transferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, agentOne))
|
||||
if transferRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("转接会话失败: %d %s", transferRecorder.Code, transferRecorder.Body.String())
|
||||
if transferRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("普通客服转接状态码 = %d,期望 %d,响应 = %s", transferRecorder.Code, http.StatusForbidden, transferRecorder.Body.String())
|
||||
}
|
||||
|
||||
managerTransferRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(managerTransferRecorder, bearerRequest(t, http.MethodPost, fmt.Sprintf("/api/sessions/%d/transfer", session.ID), transferBody, supervisor))
|
||||
if managerTransferRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("主管转接会话失败: %d %s", managerTransferRecorder.Code, managerTransferRecorder.Body.String())
|
||||
}
|
||||
|
||||
oldAgentMessageRecorder := httptest.NewRecorder()
|
||||
@@ -630,6 +636,149 @@ func TestChannelListAndToggleRequiresAdmin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomRoleCodeIsStableAndInvalidPermissionUpdateIsAtomic(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "自定义角色租户", "normal")
|
||||
admin := createUser(t, tenant.ID, "role-admin", "admin")
|
||||
|
||||
// 首次访问角色列表会初始化权限码和内置角色。
|
||||
seedRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(seedRecorder, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
|
||||
if seedRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("初始化角色失败: %d %s", seedRecorder.Code, seedRecorder.Body.String())
|
||||
}
|
||||
|
||||
createRecorder := httptest.NewRecorder()
|
||||
createBody := []byte(`{"name":"VIP 客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(createRecorder, bearerRequest(t, http.MethodPost, "/api/roles", createBody, admin))
|
||||
if createRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("创建自定义角色失败: %d %s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
var createResponse struct {
|
||||
Data model.Role `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(createRecorder.Body.Bytes(), &createResponse); err != nil {
|
||||
t.Fatalf("解析自定义角色失败: %v", err)
|
||||
}
|
||||
originalCode := createResponse.Data.Code
|
||||
if originalCode == "" {
|
||||
t.Fatalf("自定义角色缺少稳定标识: %s", createRecorder.Body.String())
|
||||
}
|
||||
|
||||
updateRecorder := httptest.NewRecorder()
|
||||
updateBody := []byte(`{"name":"VIP 专属客服","permissions":["customer.view"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(updateRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", createResponse.Data.ID), updateBody, admin))
|
||||
if updateRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("重命名自定义角色失败: %d %s", updateRecorder.Code, updateRecorder.Body.String())
|
||||
}
|
||||
var role model.Role
|
||||
if err := model.DB.First(&role, createResponse.Data.ID).Error; err != nil {
|
||||
t.Fatalf("查询重命名角色失败: %v", err)
|
||||
}
|
||||
if role.Code != originalCode {
|
||||
t.Fatalf("角色改名后标识发生变化: %q -> %q", originalCode, role.Code)
|
||||
}
|
||||
|
||||
invalidRecorder := httptest.NewRecorder()
|
||||
invalidBody := []byte(`{"permissions":["permission.not_exists"],"data_scopes":{"customer":"all"}}`)
|
||||
router.ServeHTTP(invalidRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), invalidBody, admin))
|
||||
if invalidRecorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("无效权限保存状态码 = %d,期望 %d,响应 = %s", invalidRecorder.Code, http.StatusBadRequest, invalidRecorder.Body.String())
|
||||
}
|
||||
var permissionCount int64
|
||||
model.DB.Table("role_permissions rp").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("rp.role_id = ? AND p.code = ?", role.ID, "customer.view").
|
||||
Count(&permissionCount)
|
||||
if permissionCount != 1 {
|
||||
t.Fatalf("无效保存破坏了原权限配置,customer.view 数量 = %d", permissionCount)
|
||||
}
|
||||
|
||||
customUser := createUser(t, tenant.ID, "vip-agent", role.Code)
|
||||
beforeVersionRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(beforeVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser))
|
||||
beforeVersion := beforeVersionRecorder.Header().Get("X-Permission-Version")
|
||||
if beforeVersionRecorder.Code != http.StatusOK || beforeVersion == "" {
|
||||
t.Fatalf("首次权限版本响应异常: %d version=%q body=%s", beforeVersionRecorder.Code, beforeVersion, beforeVersionRecorder.Body.String())
|
||||
}
|
||||
|
||||
validRecorder := httptest.NewRecorder()
|
||||
validBody := []byte(`{"permissions":["customer.view","customer.create"],"data_scopes":{"customer":"self"}}`)
|
||||
router.ServeHTTP(validRecorder, bearerRequest(t, http.MethodPut, fmt.Sprintf("/api/roles/%d", role.ID), validBody, admin))
|
||||
if validRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("更新自定义角色权限失败: %d %s", validRecorder.Code, validRecorder.Body.String())
|
||||
}
|
||||
|
||||
afterVersionRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(afterVersionRecorder, bearerRequest(t, http.MethodGet, "/api/me/permissions", nil, customUser))
|
||||
afterVersion := afterVersionRecorder.Header().Get("X-Permission-Version")
|
||||
if afterVersionRecorder.Code != http.StatusOK || afterVersion == "" || afterVersion == beforeVersion {
|
||||
t.Fatalf("权限更新后版本未推进: before=%q after=%q body=%s", beforeVersion, afterVersion, afterVersionRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndStatisticsDataScopesPreventCrossAgentAccess(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "数据范围租户", "normal")
|
||||
agentOne := createUser(t, tenant.ID, "scope-agent-one", "agent")
|
||||
agentTwo := createUser(t, tenant.ID, "scope-agent-two", "agent")
|
||||
customerOne := model.Customer{TenantID: tenant.ID, Name: "客服一客户", Source: "网页"}
|
||||
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客服二客户", Source: "网页"}
|
||||
if err := model.DB.Create(&customerOne).Error; err != nil {
|
||||
t.Fatalf("创建客服一客户失败: %v", err)
|
||||
}
|
||||
if err := model.DB.Create(&customerTwo).Error; err != nil {
|
||||
t.Fatalf("创建客服二客户失败: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
sessions := []model.Session{
|
||||
{TenantID: tenant.ID, CustomerID: customerOne.ID, AgentID: &agentOne.ID, Status: "ended", CreatedAt: now},
|
||||
{TenantID: tenant.ID, CustomerID: customerTwo.ID, AgentID: &agentTwo.ID, Status: "ended", CreatedAt: now},
|
||||
}
|
||||
if err := model.DB.Create(&sessions).Error; err != nil {
|
||||
t.Fatalf("创建数据范围会话失败: %v", err)
|
||||
}
|
||||
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, bearerRequest(t, http.MethodGet, "/api/customers", nil, agentOne))
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("查询本人客户失败: %d %s", listRecorder.Code, listRecorder.Body.String())
|
||||
}
|
||||
var listResponse struct {
|
||||
List []model.Customer `json:"list"`
|
||||
}
|
||||
if err := json.Unmarshal(listRecorder.Body.Bytes(), &listResponse); err != nil || len(listResponse.List) != 1 || listResponse.List[0].ID != customerOne.ID {
|
||||
t.Fatalf("客户数据范围未生效: %s", listRecorder.Body.String())
|
||||
}
|
||||
|
||||
detailRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(detailRecorder, bearerRequest(t, http.MethodGet, fmt.Sprintf("/api/customers/%d", customerTwo.ID), nil, agentOne))
|
||||
if detailRecorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("跨客服读取客户状态码 = %d,期望 %d", detailRecorder.Code, http.StatusForbidden)
|
||||
}
|
||||
var deniedCount int64
|
||||
model.DB.Model(&model.OperationLog{}).
|
||||
Where("operator_id = ? AND action = ? AND target_type = ?", agentOne.ID, "data_access_denied", "customer").
|
||||
Count(&deniedCount)
|
||||
if deniedCount != 1 {
|
||||
t.Fatalf("客户越权未记录审计日志,数量 = %d", deniedCount)
|
||||
}
|
||||
|
||||
statsRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(statsRecorder, bearerRequest(t, http.MethodGet, "/api/statistics/kpi?period=today", nil, agentOne))
|
||||
if statsRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("查询个人统计失败: %d %s", statsRecorder.Code, statsRecorder.Body.String())
|
||||
}
|
||||
var statsResponse struct {
|
||||
Data struct {
|
||||
TotalSessions int `json:"total_sessions"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(statsRecorder.Body.Bytes(), &statsResponse); err != nil || statsResponse.Data.TotalSessions != 1 {
|
||||
t.Fatalf("统计数据范围未生效: %s", statsRecorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
|
||||
router := setupRouter(t)
|
||||
tenant := createTenant(t, "业务CRUD租户", "normal")
|
||||
|
||||
@@ -84,7 +84,7 @@ func (h *SessionHandler) SendMessage(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"})
|
||||
return
|
||||
}
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "session") {
|
||||
model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq)
|
||||
session.LastReadSeq = msg.Seq
|
||||
}
|
||||
@@ -104,7 +104,7 @@ type AssignSessionReq struct {
|
||||
}
|
||||
|
||||
func isTenantManager(c *gin.Context) bool {
|
||||
return middleware.HasAnyRole(c, "admin", "supervisor")
|
||||
return middleware.CanAccessAllData(c, "session")
|
||||
}
|
||||
|
||||
func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) {
|
||||
@@ -121,21 +121,22 @@ func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) {
|
||||
}
|
||||
|
||||
func canReadSession(c *gin.Context, session *model.Session) bool {
|
||||
if isTenantManager(c) {
|
||||
module := "session"
|
||||
if session.Status == "ended" || session.Status == "archived" {
|
||||
module = "chat_history"
|
||||
}
|
||||
if middleware.CanAccessAllData(c, module) {
|
||||
return true
|
||||
}
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
userID := middleware.GetUserID(c)
|
||||
return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID)
|
||||
}
|
||||
return false
|
||||
userID := middleware.GetUserID(c)
|
||||
return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == userID)
|
||||
}
|
||||
|
||||
func canOperateSession(c *gin.Context, session *model.Session) bool {
|
||||
if isTenantManager(c) {
|
||||
return true
|
||||
}
|
||||
return middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c)
|
||||
return session.AgentID != nil && *session.AgentID == middleware.GetUserID(c)
|
||||
}
|
||||
|
||||
func broadcastSessionMessage(session *model.Session, message model.Message) {
|
||||
@@ -160,12 +161,34 @@ func loadAssignableAgent(tenantID, agentID uint) error {
|
||||
if err := model.DB.First(&agent, agentID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if agent.TenantID != tenantID || agent.Role != "agent" || agent.Status == "disabled" {
|
||||
if agent.TenantID != tenantID || agent.Status == "disabled" {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if agent.Role == "agent" {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := model.DB.Table("roles r").
|
||||
Joins("JOIN role_permissions rp ON rp.role_id = r.id").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("r.tenant_id = ? AND r.code = ? AND r.type = ? AND p.code = ?", tenantID, agent.Role, "custom", "session.reply").
|
||||
Count(&count).Error; err != nil || count == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func assignableRoleCodes(tenantID uint) []string {
|
||||
codes := []string{"agent"}
|
||||
var customCodes []string
|
||||
model.DB.Table("roles r").
|
||||
Joins("JOIN role_permissions rp ON rp.role_id = r.id").
|
||||
Joins("JOIN permissions p ON p.id = rp.permission_id").
|
||||
Where("r.tenant_id = ? AND r.type = ? AND p.code = ?", tenantID, "custom", "session.reply").
|
||||
Distinct().Pluck("r.code", &customCodes)
|
||||
return append(codes, customCodes...)
|
||||
}
|
||||
|
||||
func unreadCount(session model.Session) int {
|
||||
var count int64
|
||||
model.DB.Model(&model.Message{}).
|
||||
@@ -189,7 +212,7 @@ func (h *SessionHandler) List(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID)
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.CanAccessAllData(c, "session") || !middleware.CanAccessAllData(c, "chat_history") {
|
||||
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
|
||||
}
|
||||
if status != "" {
|
||||
@@ -283,7 +306,7 @@ func (h *SessionHandler) List(c *gin.Context) {
|
||||
items := make([]SessionListItem, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]}
|
||||
if middleware.GetRole(c) == "agent" && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
|
||||
if !middleware.CanAccessAllData(c, "session") && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) {
|
||||
item.UnreadCount = unreadCount(session)
|
||||
}
|
||||
if cu, ok := customerMap[session.CustomerID]; ok {
|
||||
@@ -444,7 +467,7 @@ func (h *SessionHandler) MarkRead(c *gin.Context) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"})
|
||||
return
|
||||
}
|
||||
if session.AgentID == nil || middleware.GetRole(c) != "agent" || *session.AgentID != middleware.GetUserID(c) {
|
||||
if session.AgentID == nil || middleware.CanAccessAllData(c, "session") || *session.AgentID != middleware.GetUserID(c) {
|
||||
middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq})
|
||||
return
|
||||
}
|
||||
@@ -496,9 +519,10 @@ func (h *SessionHandler) ListAvailableAgents(c *gin.Context) {
|
||||
}
|
||||
// all=1 返回租户全部坐席(含离线),用于对话记录筛选;
|
||||
// 默认:可转接坐席(role=agent,在线/忙碌),便于前端展示状态
|
||||
q := model.DB.Where("tenant_id = ? AND role IN ?", middleware.GetTenantID(c), []string{"agent", "supervisor", "admin"})
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
q := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, assignableRoleCodes(tenantID))
|
||||
if c.Query("all") != "1" {
|
||||
q = q.Where("role = ? AND status IN ?", "agent", []string{"online", "busy"})
|
||||
q = q.Where("status IN ?", []string{"online", "busy"})
|
||||
}
|
||||
var users []model.User
|
||||
if err := q.Order("nickname asc").Find(&users).Error; err != nil {
|
||||
@@ -584,9 +608,9 @@ func (h *SessionHandler) Assign(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"})
|
||||
return
|
||||
}
|
||||
if middleware.GetRole(c) == "agent" {
|
||||
if !middleware.HasPermission(c, "session.transfer") {
|
||||
req.AgentID = middleware.GetUserID(c)
|
||||
} else if !isTenantManager(c) {
|
||||
} else if !isTenantManager(c) && req.AgentID != middleware.GetUserID(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"})
|
||||
return
|
||||
}
|
||||
@@ -784,7 +808,7 @@ func (h *SessionHandler) UpdatePriority(c *gin.Context) {
|
||||
|
||||
// Archive 将已结束会话归档(主管/管理员)
|
||||
func (h *SessionHandler) Archive(c *gin.Context) {
|
||||
if !isTenantManager(c) {
|
||||
if !middleware.HasPermission(c, "chat_history.batch_archive") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
|
||||
return
|
||||
}
|
||||
@@ -816,7 +840,7 @@ func (h *SessionHandler) Archive(c *gin.Context) {
|
||||
|
||||
// BatchArchive 批量归档已结束会话
|
||||
func (h *SessionHandler) BatchArchive(c *gin.Context) {
|
||||
if !isTenantManager(c) {
|
||||
if !middleware.HasPermission(c, "chat_history.batch_archive") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ type updateSettingsReq struct {
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) Update(c *gin.Context) {
|
||||
if !middleware.HasAnyRole(c, "admin") {
|
||||
if !middleware.HasPermission(c, "settings.basic") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户管理员可修改设置"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ type StatisticsHandler struct{}
|
||||
func NewStatisticsHandler() *StatisticsHandler { return &StatisticsHandler{} }
|
||||
|
||||
func requireStatisticsAccess(c *gin.Context) bool {
|
||||
if middleware.HasAnyRole(c, "admin", "supervisor") {
|
||||
if middleware.HasPermission(c, "statistics.view") {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可查看统计"})
|
||||
@@ -70,9 +70,12 @@ func parseStatsRange(c *gin.Context) (statsRange, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func loadStatisticsData(tenantID uint, r statsRange) ([]model.Session, []model.Message, error) {
|
||||
func loadStatisticsData(tenantID, userID uint, allData bool, r statsRange) ([]model.Session, []model.Message, error) {
|
||||
var sessions []model.Session
|
||||
q := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if !allData {
|
||||
q = q.Where("agent_id = ?", userID)
|
||||
}
|
||||
if !r.From.IsZero() {
|
||||
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
|
||||
}
|
||||
@@ -158,7 +161,9 @@ func (h *StatisticsHandler) KPIs(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
sessions, messages, err := loadStatisticsData(middleware.GetTenantID(c), r)
|
||||
sessions, messages, err := loadStatisticsData(
|
||||
middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询统计数据失败"})
|
||||
return
|
||||
@@ -209,6 +214,9 @@ func (h *StatisticsHandler) SessionTrend(c *gin.Context) {
|
||||
|
||||
var sessions []model.Session
|
||||
q := model.DB.Where("tenant_id = ?", middleware.GetTenantID(c))
|
||||
if !middleware.CanAccessAllData(c, "statistics") {
|
||||
q = q.Where("agent_id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
// 趋势:自定义/今日/本周用区间内数据;本月预置仍看近 6 个月走势
|
||||
if hasCustom || period == "today" || period == "week" || period == "day" {
|
||||
q = q.Where("created_at >= ? AND created_at < ?", r.From, r.To)
|
||||
@@ -259,7 +267,9 @@ func (h *StatisticsHandler) ResponseDistribution(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
_, messages, err := loadStatisticsData(middleware.GetTenantID(c), r)
|
||||
_, messages, err := loadStatisticsData(
|
||||
middleware.GetTenantID(c), middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询响应时长失败"})
|
||||
return
|
||||
@@ -296,13 +306,18 @@ func (h *StatisticsHandler) AgentPerformance(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
sessions, messages, err := loadStatisticsData(tenantID, r)
|
||||
allData := middleware.CanAccessAllData(c, "statistics")
|
||||
sessions, messages, err := loadStatisticsData(tenantID, middleware.GetUserID(c), allData, r)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服绩效失败"})
|
||||
return
|
||||
}
|
||||
var agents []model.User
|
||||
if err := model.DB.Where("tenant_id = ? AND role = ?", tenantID, "agent").Find(&agents).Error; err != nil {
|
||||
agentQuery := model.DB.Where("tenant_id = ?", tenantID)
|
||||
if !allData {
|
||||
agentQuery = agentQuery.Where("id = ?", middleware.GetUserID(c))
|
||||
}
|
||||
if err := agentQuery.Find(&agents).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"})
|
||||
return
|
||||
}
|
||||
@@ -365,7 +380,9 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
tenantID := middleware.GetTenantID(c)
|
||||
sessions, _, err := loadStatisticsData(tenantID, r)
|
||||
sessions, _, err := loadStatisticsData(
|
||||
tenantID, middleware.GetUserID(c), middleware.CanAccessAllData(c, "statistics"), r,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
@@ -375,10 +392,6 @@ func (h *StatisticsHandler) ChannelDistribution(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
if err := model.DB.Where("tenant_id = ?", tenantID).Find(&channels).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询渠道分布失败"})
|
||||
return
|
||||
}
|
||||
channelTypes := make(map[uint]string)
|
||||
for _, channel := range channels {
|
||||
channelTypes[channel.ID] = channel.Type
|
||||
|
||||
Reference in New Issue
Block a user