优化客服云权限安全与访客IP解析

This commit is contained in:
yml2213
2026-08-31 13:43:45 +08:00
parent 523a306d5e
commit bdf67395ab
24 changed files with 365 additions and 48 deletions
+18
View File
@@ -3,11 +3,19 @@ package handler
import (
"fmt"
"strings"
"sync"
"kefu-cloud/server/internal/model"
"kefu-cloud/server/internal/ws"
)
var assignmentLocks sync.Map
func assignmentLock(tenantID uint) *sync.Mutex {
lock, _ := assignmentLocks.LoadOrStore(tenantID, &sync.Mutex{})
return lock.(*sync.Mutex)
}
const (
assignStrategyLeastLoad = "least_load"
assignStrategyRoundRobin = "round_robin"
@@ -138,6 +146,16 @@ func tryAutoAssign(session *model.Session) (*model.User, error) {
if session.Status != "waiting" || session.AgentID != nil {
return nil, nil
}
// 负载统计与会话更新必须串行,否则并发新会话会同时看到旧负载并突破最大并发。
lock := assignmentLock(session.TenantID)
lock.Lock()
defer lock.Unlock()
if err := model.DB.First(session, session.ID).Error; err != nil {
return nil, err
}
if session.Status != "waiting" || session.AgentID != nil {
return nil, nil
}
setting, err := getOrCreateTenantSettings(session.TenantID)
if err != nil {
+1 -1
View File
@@ -321,7 +321,7 @@ func (h *CustomerHandler) Update(c *gin.Context) {
}
func (h *CustomerHandler) Delete(c *gin.Context) {
if !middleware.HasAnyPermission(c, "customer.export") {
if !middleware.HasAnyPermission(c, "customer.delete") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"})
return
}
+2 -2
View File
@@ -21,7 +21,7 @@ func (h *CustomerHandler) Export(c *gin.Context) {
source := c.Query("source")
query := model.DB.Where("tenant_id = ?", tenantID)
if !middleware.CanAccessAllData(c, "chat_history") {
if !middleware.CanAccessAllData(c, "customer") {
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.CanAccessAllData(c, "customer") {
if !middleware.CanAccessAllData(c, "chat_history") {
query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting")
}
if status != "" {
+5 -2
View File
@@ -1,6 +1,8 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"kefu-cloud/server/internal/config"
"kefu-cloud/server/internal/middleware"
@@ -33,10 +35,11 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
api := r.Group("/api")
// 公开接口
api.POST("/login", auth.Login)
api.POST("/login", middleware.RateLimitByIP(20, time.Minute), auth.Login)
// widget 接口
widgetApi := api.Group("/widget")
widgetApi.Use(middleware.RateLimitByIP(240, time.Minute))
widgetApi.POST("/init", widget.Init)
widgetApi.GET("/init", widget.Init)
widgetApi.POST("/message", widget.SendMessage)
@@ -88,7 +91,7 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S
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)
customers.DELETE("/:id", middleware.RequirePermission("customer.delete"), customer.Delete)
// 黑名单(拉黑 IP / 设备)
bl := authRequired.Group("/blacklist")
@@ -8,6 +8,8 @@ import (
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -779,6 +781,91 @@ func TestCustomerAndStatisticsDataScopesPreventCrossAgentAccess(t *testing.T) {
}
}
func TestSettingsAndExportsHonorFineGrainedPermissions(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "细粒度权限租户", "normal")
admin := createUser(t, tenant.ID, "fine-admin", "admin")
seed := httptest.NewRecorder()
router.ServeHTTP(seed, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
if seed.Code != http.StatusOK {
t.Fatalf("初始化权限数据失败: %d %s", seed.Code, seed.Body.String())
}
createRole := httptest.NewRecorder()
roleBody := []byte(`{"name":"只读设置导出","permissions":["settings.basic","customer.view","customer.export","chat_history.view","chat_history.export"],"data_scopes":{"customer":"self","chat_history":"self"}}`)
router.ServeHTTP(createRole, bearerRequest(t, http.MethodPost, "/api/roles", roleBody, admin))
if createRole.Code != http.StatusOK {
t.Fatalf("创建测试角色失败: %d %s", createRole.Code, createRole.Body.String())
}
var roleResp struct {
Data model.Role `json:"data"`
}
if err := json.Unmarshal(createRole.Body.Bytes(), &roleResp); err != nil {
t.Fatalf("解析测试角色失败: %v", err)
}
operator := createUser(t, tenant.ID, "fine-operator", roleResp.Data.Code)
settings := httptest.NewRecorder()
router.ServeHTTP(settings, bearerRequest(t, http.MethodPut, "/api/settings", []byte(`{"assign_strategy":"round_robin"}`), operator))
if settings.Code != http.StatusForbidden {
t.Fatalf("缺少分配规则权限仍可修改设置: %d %s", settings.Code, settings.Body.String())
}
customerOne := model.Customer{TenantID: tenant.ID, Name: "客户一"}
customerTwo := model.Customer{TenantID: tenant.ID, Name: "客户二"}
if err := model.DB.Create(&customerOne).Error; err != nil {
t.Fatal(err)
}
if err := model.DB.Create(&customerTwo).Error; err != nil {
t.Fatal(err)
}
sessionOne := model.Session{TenantID: tenant.ID, CustomerID: customerOne.ID, AgentID: &operator.ID, Status: "ended"}
other := createUser(t, tenant.ID, "fine-other", "agent")
sessionTwo := model.Session{TenantID: tenant.ID, CustomerID: customerTwo.ID, AgentID: &other.ID, Status: "ended"}
if err := model.DB.Create(&sessionOne).Error; err != nil {
t.Fatal(err)
}
if err := model.DB.Create(&sessionTwo).Error; err != nil {
t.Fatal(err)
}
customerExport := httptest.NewRecorder()
router.ServeHTTP(customerExport, bearerRequest(t, http.MethodGet, "/api/customers/export", nil, operator))
if customerExport.Code != http.StatusOK || strings.Contains(customerExport.Body.String(), "客户二") {
t.Fatalf("客户导出越过 customer=self 范围: %d %s", customerExport.Code, customerExport.Body.String())
}
sessionExport := httptest.NewRecorder()
router.ServeHTTP(sessionExport, bearerRequest(t, http.MethodGet, "/api/sessions/export", nil, operator))
if sessionExport.Code != http.StatusOK || strings.Contains(sessionExport.Body.String(), ","+strconv.FormatUint(uint64(sessionTwo.ID), 10)+",") {
t.Fatalf("会话导出越过 chat_history=self 范围: %d %s", sessionExport.Code, sessionExport.Body.String())
}
}
func TestCustomerExportPermissionDoesNotGrantDelete(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "删除权限租户", "normal")
admin := createUser(t, tenant.ID, "delete-admin", "admin")
seed := httptest.NewRecorder()
router.ServeHTTP(seed, bearerRequest(t, http.MethodGet, "/api/roles", nil, admin))
createRole := httptest.NewRecorder()
router.ServeHTTP(createRole, bearerRequest(t, http.MethodPost, "/api/roles", []byte(`{"name":"仅导出","permissions":["customer.view","customer.export"]}`), admin))
var roleResp struct {
Data model.Role `json:"data"`
}
if err := json.Unmarshal(createRole.Body.Bytes(), &roleResp); err != nil {
t.Fatal(err)
}
operator := createUser(t, tenant.ID, "export-only", roleResp.Data.Code)
customer := model.Customer{TenantID: tenant.ID, Name: "不可删除客户"}
if err := model.DB.Create(&customer).Error; err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, bearerRequest(t, http.MethodDelete, "/api/customers/"+strconv.FormatUint(uint64(customer.ID), 10), nil, operator))
if rec.Code != http.StatusForbidden {
t.Fatalf("仅导出角色可删除客户: %d %s", rec.Code, rec.Body.String())
}
}
func TestCustomerAndKnowledgeCRUD(t *testing.T) {
router := setupRouter(t)
tenant := createTenant(t, "业务CRUD租户", "normal")
+43 -18
View File
@@ -24,9 +24,9 @@ const (
defaultAgentNickname = "在线客服"
defaultTimezone = "Asia/Shanghai"
maxWelcomeSegments = 10
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
maxWelcomeSegments = 10
maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计)
maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限
)
// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。
@@ -54,7 +54,7 @@ func stripHTMLTags(s string) string {
// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。
type WelcomeSegment struct {
Type string `json:"type"` // text | image
Type string `json:"type"` // text | image
Content string `json:"content"`
}
@@ -311,20 +311,20 @@ func settingsResponse(setting *model.TenantSetting) gin.H {
}
welcomeSegs := resolveWelcomeSegments(setting)
return gin.H{
"tenant_id": setting.TenantID,
"display_name": setting.DisplayName,
"agent_nickname": setting.AgentNickname,
"timezone": setting.Timezone,
"welcome_message": firstWelcomeText(welcomeSegs),
"welcome_messages": welcomeSegs,
"offline_prompt": setting.OfflinePrompt,
"work_hours": parseWorkHours(setting.WorkHoursJSON),
"worktime_prompt": setting.WorktimePrompt,
"notify_new_session": setting.NotifyNewSession,
"notify_offline_leave": setting.NotifyOfflineLeave,
"notify_daily_report": setting.NotifyDailyReport,
"assign_strategy": strategy,
"max_active_per_agent": maxActive,
"tenant_id": setting.TenantID,
"display_name": setting.DisplayName,
"agent_nickname": setting.AgentNickname,
"timezone": setting.Timezone,
"welcome_message": firstWelcomeText(welcomeSegs),
"welcome_messages": welcomeSegs,
"offline_prompt": setting.OfflinePrompt,
"work_hours": parseWorkHours(setting.WorkHoursJSON),
"worktime_prompt": setting.WorktimePrompt,
"notify_new_session": setting.NotifyNewSession,
"notify_offline_leave": setting.NotifyOfflineLeave,
"notify_daily_report": setting.NotifyDailyReport,
"assign_strategy": strategy,
"max_active_per_agent": maxActive,
}
}
@@ -379,6 +379,31 @@ func (h *SettingsHandler) Update(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
// 设置权限按字段分区校验,避免仅拥有基本设置权限即可修改分配、工作时间或通知策略。
if req.AssignStrategy != nil || req.MaxActivePerAgent != nil {
if !middleware.HasPermission(c, "settings.assign_rule") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改客服分配规则"})
return
}
}
if req.WelcomeMessage != nil || req.WelcomeMessages != nil || req.OfflinePrompt != nil {
if !middleware.HasPermission(c, "settings.auto_reply") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改自动回复设置"})
return
}
}
if req.WorkHours != nil || req.WorktimePrompt != nil {
if !middleware.HasPermission(c, "settings.worktime") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改工作时间设置"})
return
}
}
if req.NotifyNewSession != nil || req.NotifyOfflineLeave != nil || req.NotifyDailyReport != nil {
if !middleware.HasPermission(c, "settings.notification") {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改通知设置"})
return
}
}
updates := map[string]interface{}{}
if req.DisplayName != nil {
+3 -1
View File
@@ -12,7 +12,6 @@ func resolveVisitorIP(c *gin.Context) string {
candidates := []string{
c.GetHeader("CF-Connecting-IP"),
c.GetHeader("True-Client-IP"),
c.GetHeader("X-Real-IP"),
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
// 取最左侧第一个非空 IP
@@ -24,6 +23,9 @@ func resolveVisitorIP(c *gin.Context) string {
}
}
}
// Nginx 经 Caddy/网关转发时,X-Real-IP 通常是上一跳代理地址;
// X-Forwarded-For 才包含访客地址,因此放在 XFF 之后作为回退。
candidates = append(candidates, c.GetHeader("X-Real-IP"))
for _, raw := range candidates {
ip := strings.TrimSpace(raw)
if ip == "" {
@@ -23,6 +23,16 @@ func TestResolveVisitorIPAndRegion(t *testing.T) {
t.Fatalf("内网地区应为内网, got %s", region)
}
// Caddy -> Nginx 链路:X-Real-IP 是上一跳代理,XFF 左侧才是访客。
c3, _ := gin.CreateTestContext(httptest.NewRecorder())
c3.Request = httptest.NewRequest("GET", "/", nil)
c3.Request.RemoteAddr = "172.18.0.3:42100"
c3.Request.Header.Set("X-Real-IP", "172.18.0.1")
c3.Request.Header.Set("X-Forwarded-For", "203.0.113.24, 172.18.0.1")
if ip3 := resolveVisitorIP(c3); ip3 != "203.0.113.24" {
t.Fatalf("代理链 IP 解析错误: %s", ip3)
}
// CF 国家头
c2, _ := gin.CreateTestContext(httptest.NewRecorder())
c2.Request = httptest.NewRequest("GET", "/", nil)