diff --git a/.gitignore b/.gitignore index c178c5d..6b954ea 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ server/bin/ .dev/ *.exe *.test +.codex_artifacts/ diff --git a/README.md b/README.md index 4436231..3215159 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ chmod +x deploy/scripts/*.sh ### 环境要求 -- Go 1.22+(仓库 `go.mod` 声明见 `server/go.mod`) +- Go 1.26+(与仓库 `go.mod` 和生产构建镜像一致) - Node.js 20+ - Docker(数据库与 MinIO) diff --git a/deploy/.env.prod.example b/deploy/.env.prod.example index a00ed1a..a12eeb1 100644 --- a/deploy/.env.prod.example +++ b/deploy/.env.prod.example @@ -24,6 +24,7 @@ DB_SSLMODE=disable # ========== JWT(deploy.sh 可自动生成)========== JWT_SECRET=请改成随机长字符串 +CORS_ALLOW_ORIGINS=https://你的域名 # ========== MinIO 对象存储 ========== STORAGE_ACCESS_KEY=kefu_minio diff --git a/deploy/README.md b/deploy/README.md index d4341fb..069b7fa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -155,6 +155,12 @@ STORAGE_PUBLIC_BASE_URL=https://kefu.example.com/files ## HTTPS 其它方式 +### 访客 IP 显示说明 + +本地通过 Docker 网桥访问时,访客 IP 可能显示为 `172.19.0.1`;直接在本机开发服务访问时则常见为 `127.0.0.1` 或 `::1`,这两种都表示本地回环/网桥地址,不是公网 IP。 + +生产链路由 Caddy → 应用 Nginx → API 时,API 优先读取 `X-Forwarded-For` 最左侧地址,`X-Real-IP` 仅作为回退,因此外部访客会显示其公网地址。请保持示例反代配置中的 `X-Forwarded-For` 转发,不要在边缘代理处清空该请求头。 + 任选其一(与上一节「Caddy 共存」二选一即可): 1. **CLB / CDN** 终结 HTTPS,回源 CVM `HTTP_PORT`(或仍回源 Caddy) diff --git a/deploy/docker/Dockerfile.api b/deploy/docker/Dockerfile.api index 0e091df..61ba599 100644 --- a/deploy/docker/Dockerfile.api +++ b/deploy/docker/Dockerfile.api @@ -8,7 +8,7 @@ # docker build --build-arg ALPINE_MIRROR=dl-cdn.alpinelinux.org \ # --build-arg GOPROXY=https://proxy.golang.org,direct ... -FROM golang:1.24-alpine AS builder +FROM golang:1.26-alpine AS builder ARG ALPINE_MIRROR=mirrors.cloud.tencent.com ARG GOPROXY=https://goproxy.cn,direct diff --git a/deploy/scripts/dev-local.sh b/deploy/scripts/dev-local.sh index f21949a..17e744b 100755 --- a/deploy/scripts/dev-local.sh +++ b/deploy/scripts/dev-local.sh @@ -45,7 +45,7 @@ need_cmd() { if ! command -v "$1" >/dev/null 2>&1; then err "缺少命令: $1" case "$1" in - go) err "请安装 Go 1.22+:https://go.dev/dl/ 或 brew install go" ;; + go) err "请安装 Go 1.26+:https://go.dev/dl/ 或 brew install go" ;; node|npm) err "请安装 Node.js 20+:https://nodejs.org/ 或 brew install node" ;; docker) err "请安装 Docker Desktop for Mac:https://www.docker.com/products/docker-desktop/" ;; esac @@ -118,7 +118,7 @@ check_prereqs() { go_major="$(go env GOVERSION 2>/dev/null | sed -E 's/^go([0-9]+).*/\1/' || echo 0)" go_minor="$(go env GOVERSION 2>/dev/null | sed -E 's/^go[0-9]+\.([0-9]+).*/\1/' || echo 0)" if [[ "${go_major:-0}" -lt 1 ]] || { [[ "${go_major:-0}" -eq 1 && "${go_minor:-0}" -lt 22 ]]; }; then - warn "建议 Go 1.22+,当前: $(go version 2>/dev/null || echo unknown)" + warn "建议 Go 1.26+,当前: $(go version 2>/dev/null || echo unknown)" fi local node_major diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d2f6369..ca0f3e6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -88,6 +88,7 @@ services: DB_NAME: ${DB_NAME:-kefu_cloud} DB_SSLMODE: ${DB_SSLMODE:-disable} JWT_SECRET: ${JWT_SECRET:?请设置 JWT_SECRET} + CORS_ALLOW_ORIGINS: ${CORS_ALLOW_ORIGINS:-*} STORAGE_ENDPOINT: minio:9000 STORAGE_ACCESS_KEY: ${STORAGE_ACCESS_KEY} STORAGE_SECRET_KEY: ${STORAGE_SECRET_KEY} diff --git a/server/.env.example b/server/.env.example index e9a0a78..9626747 100644 --- a/server/.env.example +++ b/server/.env.example @@ -9,3 +9,4 @@ DB_NAME=kefu_cloud DB_SSLMODE=disable JWT_SECRET=kefu-cloud-dev-secret-key +CORS_ALLOW_ORIGINS=* diff --git a/server/cmd/main.go b/server/cmd/main.go index 7691250..ad0e034 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -3,6 +3,7 @@ package main import ( "context" "log" + "net/http" "time" "github.com/gin-gonic/gin" @@ -38,6 +39,7 @@ func main() { } // 启动 WebSocket Hub + ws.SetAllowedOrigins(cfg.AllowedOrigins) go ws.DefaultHub.Run() // 设置 Gin 模式 @@ -49,10 +51,25 @@ func main() { // CORS r.Use(func(c *gin.Context) { - c.Header("Access-Control-Allow-Origin", "*") + origin := c.GetHeader("Origin") + allowed := false + for _, candidate := range cfg.AllowedOrigins { + if candidate == "*" || candidate == origin { + allowed = true + break + } + } + if allowed && origin != "" { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Vary", "Origin") + } c.Header("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") c.Header("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Visitor-Token") if c.Request.Method == "OPTIONS" { + if origin != "" && !allowed { + c.AbortWithStatus(http.StatusForbidden) + return + } c.AbortWithStatus(204) return } diff --git a/server/internal/config/config.go b/server/internal/config/config.go index a9b1754..84c8d26 100644 --- a/server/internal/config/config.go +++ b/server/internal/config/config.go @@ -3,14 +3,16 @@ package config import ( "os" "strconv" + "strings" "time" ) type Config struct { - Server ServerConfig - Database DatabaseConfig - JWT JWTConfig - Storage StorageConfig + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + Storage StorageConfig + AllowedOrigins []string } type ServerConfig struct { @@ -77,9 +79,24 @@ func Load() *Config { MaxImageEdge: getEnvInt("STORAGE_MAX_IMAGE_EDGE", 1920), WebPQuality: float32(getEnvInt("STORAGE_WEBP_QUALITY", 80)), }, + AllowedOrigins: splitCSV(getEnv("CORS_ALLOW_ORIGINS", "*")), } } +func splitCSV(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + if value := strings.TrimSpace(part); value != "" { + result = append(result, value) + } + } + if len(result) == 0 { + return []string{"*"} + } + return result +} + func (d *DatabaseConfig) DSN() string { return "host=" + d.Host + " port=" + d.Port + " user=" + d.User + " password=" + d.Password + " dbname=" + d.Name + " sslmode=" + d.SSLMode diff --git a/server/internal/handler/assignment.go b/server/internal/handler/assignment.go index 0b35b44..74daef2 100644 --- a/server/internal/handler/assignment.go +++ b/server/internal/handler/assignment.go @@ -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 { diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 3c25beb..531bb38 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -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 } diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go index 9408e00..112d338 100644 --- a/server/internal/handler/export.go +++ b/server/internal/handler/export.go @@ -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 != "" { diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 17c84bf..18c3ffe 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -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") diff --git a/server/internal/handler/security_integration_test.go b/server/internal/handler/security_integration_test.go index 7435017..09390f5 100644 --- a/server/internal/handler/security_integration_test.go +++ b/server/internal/handler/security_integration_test.go @@ -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") diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index 12d6368..790c038 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -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 { diff --git a/server/internal/handler/visitor_meta.go b/server/internal/handler/visitor_meta.go index 5796b98..027e8c8 100644 --- a/server/internal/handler/visitor_meta.go +++ b/server/internal/handler/visitor_meta.go @@ -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 == "" { diff --git a/server/internal/handler/visitor_meta_test.go b/server/internal/handler/visitor_meta_test.go index e8e7e54..24f4c12 100644 --- a/server/internal/handler/visitor_meta_test.go +++ b/server/internal/handler/visitor_meta_test.go @@ -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) diff --git a/server/internal/middleware/permission.go b/server/internal/middleware/permission.go index 2f75c66..aa6f108 100644 --- a/server/internal/middleware/permission.go +++ b/server/internal/middleware/permission.go @@ -197,7 +197,7 @@ func legacyRolePermissions(role string) map[string]bool { "session.view": true, "session.reply": true, "session.transfer": true, "session.end": true, "session.note": true, "session.priority": true, "customer.view": true, "customer.create": true, "customer.edit": true, - "customer.export": true, "customer.tag": true, + "customer.export": true, "customer.tag": true, "customer.delete": true, "chat_history.view": true, "chat_history.detail": true, "chat_history.export": true, "chat_history.batch_archive": true, "knowledge.view": true, "knowledge.create": true, "knowledge.edit": true, @@ -216,7 +216,7 @@ func legacyRolePermissions(role string) map[string]bool { "session.view": true, "session.reply": true, "session.transfer": true, "session.end": true, "session.note": true, "session.priority": true, "customer.view": true, "customer.create": true, "customer.edit": true, - "customer.export": true, "customer.tag": true, + "customer.export": true, "customer.tag": true, "customer.delete": true, "chat_history.view": true, "chat_history.detail": true, "chat_history.export": true, "chat_history.batch_archive": true, "knowledge.view": true, "knowledge.create": true, "knowledge.edit": true, diff --git a/server/internal/middleware/rate_limit.go b/server/internal/middleware/rate_limit.go new file mode 100644 index 0000000..473af15 --- /dev/null +++ b/server/internal/middleware/rate_limit.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type rateWindow struct { + start time.Time + count int +} + +var rateWindows sync.Map +var rateMu sync.Mutex + +// RateLimitByIP applies a fixed-window limit to public endpoints. +// A shared gateway limiter is still needed when running multiple replicas. +func RateLimitByIP(max int, window time.Duration) gin.HandlerFunc { + if max < 1 { + max = 1 + } + if window <= 0 { + window = time.Minute + } + return func(c *gin.Context) { + key := c.Request.Method + ":" + c.FullPath() + ":" + c.ClientIP() + now := time.Now() + rateMu.Lock() + allowed := false + var current rateWindow + if raw, ok := rateWindows.Load(key); ok { + current, _ = raw.(rateWindow) + } + if current.start.IsZero() || now.Sub(current.start) >= window { + current = rateWindow{start: now, count: 1} + allowed = true + } else if current.count < max { + current.count++ + allowed = true + } + rateWindows.Store(key, current) + rateMu.Unlock() + if !allowed { + c.Header("Retry-After", "60") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"code": 429, "message": "请求过于频繁,请稍后重试"}) + return + } + c.Next() + } +} diff --git a/server/internal/middleware/rate_limit_test.go b/server/internal/middleware/rate_limit_test.go new file mode 100644 index 0000000..47e766d --- /dev/null +++ b/server/internal/middleware/rate_limit_test.go @@ -0,0 +1,28 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +func TestRateLimitByIPReturns429AfterWindowIsConsumed(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/rate-test", RateLimitByIP(1, time.Minute), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + first := httptest.NewRecorder() + r.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/rate-test", nil)) + if first.Code != http.StatusNoContent { + t.Fatalf("首个请求状态码 = %d", first.Code) + } + second := httptest.NewRecorder() + r.ServeHTTP(second, httptest.NewRequest(http.MethodGet, "/rate-test", nil)) + if second.Code != http.StatusTooManyRequests { + t.Fatalf("超限请求状态码 = %d,期望 429", second.Code) + } +} diff --git a/server/internal/model/seed_permissions.go b/server/internal/model/seed_permissions.go index 9c39c87..fb35139 100644 --- a/server/internal/model/seed_permissions.go +++ b/server/internal/model/seed_permissions.go @@ -24,16 +24,17 @@ var predefinedPermissions = []Permission{ {Code: "customer.edit", Name: "编辑客户资料", Module: "客户管理", Category: "operate", SortOrder: 9}, {Code: "customer.export", Name: "导出客户", Module: "客户管理", Category: "operate", SortOrder: 10}, {Code: "customer.tag", Name: "管理客户标签", Module: "客户管理", Category: "operate", SortOrder: 11}, - {Code: "chat_history.view", Name: "查看对话记录", Module: "对话记录", Category: "view", SortOrder: 12}, - {Code: "chat_history.detail", Name: "查看会话详情", Module: "对话记录", Category: "view", SortOrder: 13}, - {Code: "chat_history.export", Name: "导出对话记录", Module: "对话记录", Category: "operate", SortOrder: 14}, - {Code: "chat_history.batch_archive", Name: "批量归档记录", Module: "对话记录", Category: "operate", SortOrder: 15}, - {Code: "knowledge.view", Name: "查看知识库", Module: "知识库", Category: "view", SortOrder: 16}, - {Code: "knowledge.create", Name: "新建知识条目", Module: "知识库", Category: "operate", SortOrder: 17}, - {Code: "knowledge.edit", Name: "编辑知识条目", Module: "知识库", Category: "operate", SortOrder: 18}, - {Code: "knowledge.publish", Name: "发布/下架条目", Module: "知识库", Category: "operate", SortOrder: 19}, - {Code: "knowledge.delete", Name: "删除知识条目/分类", Module: "知识库", Category: "operate", SortOrder: 20}, - {Code: "quick_reply.view", Name: "查看快捷回复", Module: "快捷回复", Category: "view", SortOrder: 21}, + {Code: "customer.delete", Name: "删除客户", Module: "客户管理", Category: "operate", SortOrder: 12}, + {Code: "chat_history.view", Name: "查看对话记录", Module: "对话记录", Category: "view", SortOrder: 13}, + {Code: "chat_history.detail", Name: "查看会话详情", Module: "对话记录", Category: "view", SortOrder: 14}, + {Code: "chat_history.export", Name: "导出对话记录", Module: "对话记录", Category: "operate", SortOrder: 15}, + {Code: "chat_history.batch_archive", Name: "批量归档记录", Module: "对话记录", Category: "operate", SortOrder: 16}, + {Code: "knowledge.view", Name: "查看知识库", Module: "知识库", Category: "view", SortOrder: 17}, + {Code: "knowledge.create", Name: "新建知识条目", Module: "知识库", Category: "operate", SortOrder: 18}, + {Code: "knowledge.edit", Name: "编辑知识条目", Module: "知识库", Category: "operate", SortOrder: 19}, + {Code: "knowledge.publish", Name: "发布/下架条目", Module: "知识库", Category: "operate", SortOrder: 20}, + {Code: "knowledge.delete", Name: "删除知识条目/分类", Module: "知识库", Category: "operate", SortOrder: 21}, + {Code: "quick_reply.view", Name: "查看快捷回复", Module: "快捷回复", Category: "view", SortOrder: 22}, {Code: "quick_reply.team_create", Name: "新建团队快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 22}, {Code: "quick_reply.team_edit", Name: "编辑/删除团队回复", Module: "快捷回复", Category: "operate", SortOrder: 23}, {Code: "quick_reply.personal_manage", Name: "管理我的快捷回复", Module: "快捷回复", Category: "operate", SortOrder: 24}, @@ -89,7 +90,7 @@ func BuiltinRolePermissionCodes() map[string][]string { return map[string][]string{ "admin": { "session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority", - "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", + "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", "customer.delete", "chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive", "knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete", "quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage", @@ -101,7 +102,7 @@ func BuiltinRolePermissionCodes() map[string][]string { }, "supervisor": { "session.view", "session.reply", "session.transfer", "session.end", "session.note", "session.priority", - "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", + "customer.view", "customer.create", "customer.edit", "customer.export", "customer.tag", "customer.delete", "chat_history.view", "chat_history.detail", "chat_history.export", "chat_history.batch_archive", "knowledge.view", "knowledge.create", "knowledge.edit", "knowledge.publish", "knowledge.delete", "quick_reply.view", "quick_reply.team_create", "quick_reply.team_edit", "quick_reply.personal_manage", diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index 14b8e2a..fe8ffbd 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -24,10 +24,40 @@ func processDraftContacts(tenantID, customerID, sessionID uint, text string) { } var upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { return true }, + CheckOrigin: checkOrigin, Subprotocols: []string{"kefu-v1", "kefu-visitor-v1"}, } +var ( + originMu sync.RWMutex + allowedOrigins = []string{"*"} +) + +// SetAllowedOrigins configures the origins accepted during WebSocket upgrade. +func SetAllowedOrigins(origins []string) { + originMu.Lock() + defer originMu.Unlock() + allowedOrigins = append([]string(nil), origins...) + if len(allowedOrigins) == 0 { + allowedOrigins = []string{"*"} + } +} + +func checkOrigin(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + originMu.RLock() + defer originMu.RUnlock() + for _, allowed := range allowedOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false +} + type Client struct { Conn *websocket.Conn UserID uint diff --git a/server/internal/ws/ws_test.go b/server/internal/ws/ws_test.go index adf5247..3b30497 100644 --- a/server/internal/ws/ws_test.go +++ b/server/internal/ws/ws_test.go @@ -1,10 +1,26 @@ package ws import ( + "net/http/httptest" "testing" "time" ) +func TestCheckOriginUsesConfiguredAllowlist(t *testing.T) { + SetAllowedOrigins([]string{"https://portal.example"}) + defer SetAllowedOrigins([]string{"*"}) + allowed := httptest.NewRequest("GET", "/api/ws", nil) + allowed.Header.Set("Origin", "https://portal.example") + if !checkOrigin(allowed) { + t.Fatal("白名单来源应允许 WebSocket 升级") + } + denied := httptest.NewRequest("GET", "/api/ws", nil) + denied.Header.Set("Origin", "https://evil.example") + if checkOrigin(denied) { + t.Fatal("非白名单来源不应允许 WebSocket 升级") + } +} + func TestBroadcastToSessionRestrictsRecipients(t *testing.T) { hub := NewHub() go hub.Run()