From 8b9f3d710fd5ccb98e3b6fa52cc11ed81b1f74e1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 5 Sep 2026 14:39:34 +0800 Subject: [PATCH] =?UTF-8?q?refactor(server):=20=E4=BC=9A=E8=AF=9D/?= =?UTF-8?q?=E8=AE=BF=E5=AE=A2/=E8=AE=BE=E7=BD=AE=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E4=B8=8B=E6=B2=89=20internal/service=20?= =?UTF-8?q?=E5=B1=82=EF=BC=8Chandler=20=E7=98=A6=E8=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留 - 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应 - session 875→361 行、widget 745→321 行、settings 540→73 行 - 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包 - 相关单元测试随迁 service 包,go test ./... 全部通过 --- server/internal/handler/assignment_test.go | 49 -- server/internal/handler/blacklist.go | 63 +- .../internal/handler/contact_extract_test.go | 28 - server/internal/handler/customer.go | 13 +- server/internal/handler/export.go | 7 +- .../handler/message_validation_test.go | 25 - server/internal/handler/page_track_test.go | 40 - server/internal/handler/router.go | 5 +- server/internal/handler/session.go | 778 +++--------------- server/internal/handler/settings.go | 507 +----------- .../internal/handler/settings_welcome_test.go | 68 -- server/internal/handler/upload.go | 10 +- server/internal/handler/widget.go | 574 ++----------- .../{handler => service}/assignment.go | 37 +- server/internal/service/blacklist.go | 58 ++ .../{handler => service}/contact_extract.go | 33 +- server/internal/service/errors.go | 21 + .../message_validation.go | 6 +- .../{handler => service}/page_track.go | 11 +- server/internal/service/session.go | 651 +++++++++++++++ server/internal/service/tenant_settings.go | 487 +++++++++++ .../tenant_settings_test.go} | 79 +- server/internal/service/util.go | 59 ++ server/internal/service/widget.go | 512 ++++++++++++ 24 files changed, 2158 insertions(+), 1963 deletions(-) delete mode 100644 server/internal/handler/assignment_test.go delete mode 100644 server/internal/handler/contact_extract_test.go delete mode 100644 server/internal/handler/message_validation_test.go delete mode 100644 server/internal/handler/page_track_test.go delete mode 100644 server/internal/handler/settings_welcome_test.go rename server/internal/{handler => service}/assignment.go (85%) create mode 100644 server/internal/service/blacklist.go rename server/internal/{handler => service}/contact_extract.go (80%) create mode 100644 server/internal/service/errors.go rename server/internal/{handler => service}/message_validation.go (85%) rename server/internal/{handler => service}/page_track.go (89%) create mode 100644 server/internal/service/session.go create mode 100644 server/internal/service/tenant_settings.go rename server/internal/{handler/settings_workhours_test.go => service/tenant_settings_test.go} (54%) create mode 100644 server/internal/service/util.go create mode 100644 server/internal/service/widget.go diff --git a/server/internal/handler/assignment_test.go b/server/internal/handler/assignment_test.go deleted file mode 100644 index 03b3ecd..0000000 --- a/server/internal/handler/assignment_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package handler - -import ( - "testing" - - "kefu-cloud/server/internal/model" -) - -func TestPickLeastLoadedPrefersFewerSessions(t *testing.T) { - a := model.User{ID: 1} - b := model.User{ID: 2} - c := model.User{ID: 3} - candidates := []model.User{a, b, c} - counts := map[uint]int64{1: 3, 2: 1, 3: 1} - got := pickLeastLoadedAgent(candidates, counts) - if got == nil || got.ID != 2 { - t.Fatalf("期望 id=2(平局取更小 id),got=%v", got) - } -} - -func TestPickRoundRobinAdvances(t *testing.T) { - candidates := []model.User{{ID: 10}, {ID: 20}, {ID: 30}} - last := uint(10) - got := pickRoundRobinAgent(candidates, &last) - if got == nil || got.ID != 20 { - t.Fatalf("期望下一位 20,got=%v", got) - } - last = 30 - got = pickRoundRobinAgent(candidates, &last) - if got == nil || got.ID != 10 { - t.Fatalf("期望绕回 10,got=%v", got) - } - got = pickRoundRobinAgent(candidates, nil) - if got == nil || got.ID != 10 { - t.Fatalf("无游标时期望第一位 10,got=%v", got) - } -} - -func TestNormalizeAssignStrategy(t *testing.T) { - if normalizeAssignStrategy("round_robin") != assignStrategyRoundRobin { - t.Fatal("round_robin") - } - if normalizeAssignStrategy("") != assignStrategyLeastLoad { - t.Fatal("default least_load") - } - if normalizeAssignStrategy("unknown") != assignStrategyLeastLoad { - t.Fatal("unknown -> least_load") - } -} diff --git a/server/internal/handler/blacklist.go b/server/internal/handler/blacklist.go index 1f6270d..fc4eca0 100644 --- a/server/internal/handler/blacklist.go +++ b/server/internal/handler/blacklist.go @@ -1,8 +1,6 @@ package handler import ( - "crypto/sha256" - "encoding/hex" "fmt" "net/http" "strings" @@ -12,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" "kefu-cloud/server/internal/ws" ) @@ -19,53 +18,6 @@ type BlacklistHandler struct{} func NewBlacklistHandler() *BlacklistHandler { return &BlacklistHandler{} } -// normalizeDeviceKey 规范化设备指纹:优先客户端 device_id;否则用 UA 摘要哈希兜底。 -func normalizeDeviceKey(deviceID, userAgent string) string { - id := strings.TrimSpace(deviceID) - if id != "" { - if utf8.RuneCountInString(id) > 64 { - id = string([]rune(id)[:64]) - } - return id - } - ua := strings.TrimSpace(userAgent) - if ua == "" { - return "" - } - sum := sha256.Sum256([]byte(strings.ToLower(ua))) - return hex.EncodeToString(sum[:])[:32] -} - -// isBlacklisted 检查租户下 IP/设备是否在有效黑名单中。 -func isBlacklisted(tenantID uint, kind, value string) (bool, *model.BlacklistEntry) { - value = strings.TrimSpace(value) - if value == "" || (kind != "ip" && kind != "device") { - return false, nil - } - now := time.Now() - var entry model.BlacklistEntry - err := model.DB. - Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value). - Where("expires_at IS NULL OR expires_at > ?", now). - Order("id desc"). - First(&entry).Error - if err != nil { - return false, nil - } - return true, &entry -} - -// checkVisitorBlacklist 同时检查 IP 与设备。 -func checkVisitorBlacklist(tenantID uint, ip, deviceKey string) (blocked bool, kind string, reason string) { - if ok, e := isBlacklisted(tenantID, "ip", ip); ok { - return true, "ip", e.Reason - } - if ok, e := isBlacklisted(tenantID, "device", deviceKey); ok { - return true, "device", e.Reason - } - return false, "", "" -} - type CreateBlacklistReq struct { // SessionID 从会话一键拉黑时必填(自动带出 IP/设备) SessionID *uint `json:"session_id"` @@ -163,8 +115,9 @@ func (h *BlacklistHandler) Create(c *gin.Context) { var customerID *uint if req.SessionID != nil && *req.SessionID > 0 { - s, ok := loadTenantSession(c, fmt.Sprintf("%d", *req.SessionID)) - if !ok { + s, err := service.LoadTenantSession(fmt.Sprintf("%d", *req.SessionID), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } session = s @@ -182,7 +135,7 @@ func (h *BlacklistHandler) Create(c *gin.Context) { value = strings.TrimSpace(s.DeviceKey) if value == "" { // 旧会话兜底:用 UA 哈希 - value = normalizeDeviceKey("", s.UserAgent) + value = service.NormalizeDeviceKey("", s.UserAgent) } if value == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "该会话无设备标识,无法按设备拉黑"}) @@ -250,7 +203,7 @@ func (h *BlacklistHandler) Create(c *gin.Context) { if kind == "device" { kindLabel = "设备" } - detail := userDisplayName(operatorID) + " 拉黑" + kindLabel + " " + maskBlacklistValue(kind, value) + + detail := service.UserDisplayName(operatorID) + " 拉黑" + kindLabel + " " + maskBlacklistValue(kind, value) + "(" + durationLabel(req.Duration) + "):" + reason model.DB.Create(&model.SessionEvent{ SessionID: session.ID, @@ -274,12 +227,12 @@ func (h *BlacklistHandler) Create(c *gin.Context) { Action: "end", Detail: "结束会话: 拉黑访客", }) - broadcastSessionUpdate(session) + service.BroadcastSessionUpdate(session) if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) } } else { - broadcastSessionUpdate(session) + service.BroadcastSessionUpdate(session) } } diff --git a/server/internal/handler/contact_extract_test.go b/server/internal/handler/contact_extract_test.go deleted file mode 100644 index c90500d..0000000 --- a/server/internal/handler/contact_extract_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package handler - -import "testing" - -func TestExtractContactsFromText(t *testing.T) { - text := "你好,我微信是 abc_wx_01,手机 13800138000,邮箱 test@example.com QQ:123456" - got := extractContactsFromText(text) - kinds := map[string]string{} - for _, c := range got { - kinds[c.Kind] = c.Value - } - if kinds["phone"] != "13800138000" { - t.Fatalf("phone: %+v", got) - } - if kinds["wechat"] != "abc_wx_01" { - t.Fatalf("wechat: %+v", got) - } - if kinds["email"] != "test@example.com" { - t.Fatalf("email: %+v", got) - } - if kinds["qq"] != "123456" { - t.Fatalf("qq: %+v", got) - } - // 不完整不应误提 - if len(extractContactsFromText("微信 ab")) != 0 { - t.Fatal("partial wechat should not extract") - } -} diff --git a/server/internal/handler/customer.go b/server/internal/handler/customer.go index 531bb38..b27f2bd 100644 --- a/server/internal/handler/customer.go +++ b/server/internal/handler/customer.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" ) type CustomerHandler struct{} @@ -85,7 +86,7 @@ func (h *CustomerHandler) Get(c *gin.Context) { sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c)) } sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) - contacts := listCustomerContacts(customer.ID) + contacts := service.ListCustomerContacts(customer.ID) middleware.JSON(c, gin.H{ "customer": customer, @@ -207,18 +208,18 @@ func (h *CustomerHandler) Create(c *gin.Context) { } syncCustomerPhones(tenantID, customer.ID, phones) // 主字段微信/邮箱/QQ 也写入 contacts(便于统一展示) - var extras []extractedContact + var extras []service.ExtractedContact if customer.Wechat != "" { - extras = append(extras, extractedContact{Kind: "wechat", Value: customer.Wechat}) + extras = append(extras, service.ExtractedContact{Kind: "wechat", Value: customer.Wechat}) } if customer.QQ != "" { - extras = append(extras, extractedContact{Kind: "qq", Value: customer.QQ}) + extras = append(extras, service.ExtractedContact{Kind: "qq", Value: customer.QQ}) } if customer.Email != "" { - extras = append(extras, extractedContact{Kind: "email", Value: customer.Email}) + extras = append(extras, service.ExtractedContact{Kind: "email", Value: customer.Email}) } if len(extras) > 0 { - _, _ = mergeCustomerContacts(tenantID, customer.ID, extras, "agent") + _, _ = service.MergeCustomerContacts(tenantID, customer.ID, extras, "agent") } middleware.JSON(c, customer) diff --git a/server/internal/handler/export.go b/server/internal/handler/export.go index 112d338..a72f803 100644 --- a/server/internal/handler/export.go +++ b/server/internal/handler/export.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" ) // Export 导出客户列表 CSV(沿用列表筛选与坐席可见范围)。 @@ -128,7 +129,7 @@ func (h *SessionHandler) Export(c *gin.Context) { customerMap := map[uint]model.Customer{} if len(customerIDs) > 0 { var customers []model.Customer - model.DB.Where("id IN ?", uniqueUint(customerIDs)).Find(&customers) + model.DB.Where("id IN ?", service.UniqueUint(customerIDs)).Find(&customers) for _, cu := range customers { customerMap[cu.ID] = cu } @@ -136,7 +137,7 @@ func (h *SessionHandler) Export(c *gin.Context) { agentMap := map[uint]model.User{} if len(agentIDs) > 0 { var users []model.User - model.DB.Where("id IN ?", uniqueUint(agentIDs)).Find(&users) + model.DB.Where("id IN ?", service.UniqueUint(agentIDs)).Find(&users) for _, u := range users { agentMap[u.ID] = u } @@ -144,7 +145,7 @@ func (h *SessionHandler) Export(c *gin.Context) { channelMap := map[uint]model.Channel{} if len(channelIDs) > 0 { var channels []model.Channel - model.DB.Where("id IN ?", uniqueUint(channelIDs)).Find(&channels) + model.DB.Where("id IN ?", service.UniqueUint(channelIDs)).Find(&channels) for _, ch := range channels { channelMap[ch.ID] = ch } diff --git a/server/internal/handler/message_validation_test.go b/server/internal/handler/message_validation_test.go deleted file mode 100644 index 29b329c..0000000 --- a/server/internal/handler/message_validation_test.go +++ /dev/null @@ -1,25 +0,0 @@ -package handler - -import "testing" - -func TestValidateMessageContent(t *testing.T) { - SetImagePublicBase("http://localhost:9100/kefu") - - if _, err := validateMessageContent("text", "hello"); err != nil { - t.Fatalf("文本消息应通过: %v", err) - } - if _, err := validateMessageContent("text", ""); err == nil { - t.Fatal("空文本应失败") - } - - validURL := "http://localhost:9100/kefu/tenants/1/chat/a.webp" - if content, err := validateMessageContent("image", validURL); err != nil || content != validURL { - t.Fatalf("合法图片 URL 校验失败: content=%q err=%v", content, err) - } - if _, err := validateMessageContent("image", "data:image/png;base64,aGVsbG8="); err == nil { - t.Fatal("base64 图片应被拒绝") - } - if _, err := validateMessageContent("image", "https://evil.example.com/a.webp"); err == nil { - t.Fatal("外域图片 URL 应被拒绝") - } -} diff --git a/server/internal/handler/page_track_test.go b/server/internal/handler/page_track_test.go deleted file mode 100644 index e287775..0000000 --- a/server/internal/handler/page_track_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package handler - -import ( - "strings" - "testing" - "unicode/utf8" -) - -func TestSanitizePageURL(t *testing.T) { - got := sanitizePageURL("https://shop.example.com/p/1?token=secret&ok=1#/cart") - if got == "" || strings.Contains(got, "token=") { - t.Fatalf("sanitize failed: %q", got) - } - if !strings.Contains(got, "ok=1") { - t.Fatalf("should keep ok param: %q", got) - } - // SPA hash 路由必须保留,否则换页轨迹会被去重丢掉 - if !strings.Contains(got, "#/cart") { - t.Fatalf("should keep hash fragment for SPA: %q", got) - } - home := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/") - cart := sanitizePageURL("http://localhost:5173/demo-shop/index.html#/cart") - if home == "" || cart == "" || home == cart { - t.Fatalf("hash pages must differ: home=%q cart=%q", home, cart) - } - if sanitizePageURL("javascript:alert(1)") != "" { - t.Fatal("reject javascript") - } - if sanitizePageURL("") != "" { - t.Fatal("empty") - } -} - -func TestSanitizePageTitle(t *testing.T) { - long := strings.Repeat("测", 300) - got := sanitizePageTitle(long) - if utf8.RuneCountInString(got) != maxPageTitleLen { - t.Fatalf("title len %d", utf8.RuneCountInString(got)) - } -} diff --git a/server/internal/handler/router.go b/server/internal/handler/router.go index 18c3ffe..74ed689 100644 --- a/server/internal/handler/router.go +++ b/server/internal/handler/router.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "kefu-cloud/server/internal/config" "kefu-cloud/server/internal/middleware" + "kefu-cloud/server/internal/service" "kefu-cloud/server/internal/storage" "kefu-cloud/server/internal/ws" ) @@ -28,9 +29,9 @@ func SetupRoutes(r *gin.Engine, store storage.ObjectStorage, storageCfg config.S upload := NewUploadHandler(store, storageCfg) perm := NewPermissionHandler() - SetImagePublicBase(storageCfg.PublicBase) + service.SetImagePublicBase(storageCfg.PublicBase) // 访客输入草稿 → 提取联系方式(由 ws 包回调,避免循环依赖) - ws.ProcessDraftContacts = onVisitorDraftContacts + ws.ProcessDraftContacts = service.OnVisitorDraftContacts api := r.Group("/api") diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index 27fac08..22a6423 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -1,18 +1,14 @@ package handler import ( + "errors" "net/http" - "sort" "strconv" "strings" - "time" - "unicode/utf8" "github.com/gin-gonic/gin" - "gorm.io/gorm" "kefu-cloud/server/internal/middleware" - "kefu-cloud/server/internal/model" - "kefu-cloud/server/internal/ws" + "kefu-cloud/server/internal/service" ) type SessionHandler struct{} @@ -24,75 +20,10 @@ type SendMessageReq struct { Type string `json:"type"` } -type SessionListItem struct { - model.Session - UnreadCount int `json:"unread_count"` - LastMessage string `json:"last_message"` - LastMessageAt *time.Time `json:"last_message_at,omitempty"` - MessageCount int `json:"message_count"` - CustomerName string `json:"customer_name"` - AgentName string `json:"agent_name"` - ChannelName string `json:"channel_name"` - ChannelType string `json:"channel_type"` -} - type CreateNoteReq struct { Content string `json:"content" binding:"required"` } -func (h *SessionHandler) SendMessage(c *gin.Context) { - userID := middleware.GetUserID(c) - id := c.Param("id") - - var req SendMessageReq - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) - return - } - if req.Type == "" { - req.Type = "text" - } - content, err := validateMessageContent(req.Type, req.Content) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) - return - } - - session, ok := loadTenantSession(c, id) - if !ok { - return - } - if !canOperateSession(c, session) { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权向该会话发送消息"}) - return - } - if session.Status == "ended" || session.Status == "archived" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) - return - } - - msg := model.Message{ - SessionID: session.ID, - SenderType: "agent", - SenderID: &userID, - Content: content, - Type: req.Type, - SentAt: time.Now(), - } - - if err := model.CreateMessage(&msg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) - return - } - if !middleware.CanAccessAllData(c, "session") { - model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq) - session.LastReadSeq = msg.Seq - } - broadcastSessionMessage(session, msg) - - middleware.JSON(c, msg) -} - type CreateSessionReq struct { ChannelID uint `json:"channel_id"` CustomerID uint `json:"customer_id"` @@ -103,309 +34,108 @@ type AssignSessionReq struct { AgentID uint `json:"agent_id"` } -func isTenantManager(c *gin.Context) bool { - return middleware.CanAccessAllData(c, "session") -} - -func loadTenantSession(c *gin.Context, id string) (*model.Session, bool) { - var session model.Session - if err := model.DB.First(&session, id).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) - return nil, false - } - if session.TenantID != middleware.GetTenantID(c) { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权访问其他租户会话"}) - return nil, false - } - return &session, true -} - -func canReadSession(c *gin.Context, session *model.Session) bool { - module := "session" - if session.Status == "ended" || session.Status == "archived" { - module = "chat_history" - } - if middleware.CanAccessAllData(c, module) { - return true - } - 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 session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) -} - -func broadcastSessionMessage(session *model.Session, message model.Message) { - payload, err := ws.NewEvent("message", session.ID, message) - if err == nil { - ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) +// sessionActor 从鉴权中间件结果构造 service 层发起者。 +func sessionActor(c *gin.Context) service.Actor { + return service.Actor{ + UserID: middleware.GetUserID(c), + SessionAll: middleware.CanAccessAllData(c, "session"), + HistoryAll: middleware.CanAccessAllData(c, "chat_history"), } } -func broadcastSessionUpdate(session *model.Session) { - payload, err := ws.NewEvent("session_updated", session.ID, gin.H{"status": session.Status}) - if err != nil { +// respondServiceError 将 service 业务错误映射为 HTTP 响应。 +func respondServiceError(c *gin.Context, err error) { + var be *service.Error + if errors.As(err, &be) { + c.JSON(be.Status, gin.H{"code": be.Status, "message": be.Message}) return } - // 坐席列表需要刷新;访客也必须收到 ended/active,才能弹出评价或更新接待状态 - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - ws.DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload) + c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "服务器内部错误"}) } -func loadAssignableAgent(tenantID, agentID uint) error { - var agent model.User - if err := model.DB.First(&agent, agentID).Error; err != nil { - return err +func (h *SessionHandler) SendMessage(c *gin.Context) { + var req SendMessageReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) + return } - if agent.TenantID != tenantID || agent.Status == "disabled" { - return gorm.ErrRecordNotFound + if req.Type == "" { + req.Type = "text" } - if agent.Role == "agent" { - return nil + // 先校验内容,再加载会话(与原校验顺序一致) + content, err := service.ValidateMessageContent(req.Type, req.Content) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) + return } - 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 + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) + return } - 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{}). - Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq). - Count(&count) - return int(count) + msg, err := service.AgentSendMessage(session, sessionActor(c), req.Type, content) + if err != nil { + respondServiceError(c, err) + return + } + middleware.JSON(c, msg) } func (h *SessionHandler) List(c *gin.Context) { - tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) - status := c.Query("status") - priority := c.Query("priority") - agentID := c.Query("agent_id") - channelID := c.Query("channel_id") - search := strings.TrimSpace(c.Query("search")) - from := c.Query("from") - to := c.Query("to") - - var sessions []model.Session - var total int64 - - query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) - if !middleware.CanAccessAllData(c, "session") || !middleware.CanAccessAllData(c, "chat_history") { - query = query.Where("sessions.agent_id = ? OR sessions.status = ?", middleware.GetUserID(c), "waiting") + f := service.SessionFilter{ + Status: c.Query("status"), + Priority: c.Query("priority"), + AgentID: c.Query("agent_id"), + ChannelID: c.Query("channel_id"), + Search: strings.TrimSpace(c.Query("search")), + From: c.Query("from"), + To: c.Query("to"), + Page: page, + PageSize: pageSize, } - if status != "" { - query = query.Where("sessions.status = ?", status) - } - if priority != "" { - query = query.Where("sessions.priority = ?", priority) - } - if agentID != "" { - query = query.Where("sessions.agent_id = ?", agentID) - } - if channelID != "" { - query = query.Where("sessions.channel_id = ?", channelID) - } - if from != "" { - if t, err := time.ParseInLocation("2006-01-02", from, time.Local); err == nil { - query = query.Where("sessions.created_at >= ?", t) - } - } - if to != "" { - if t, err := time.ParseInLocation("2006-01-02", to, time.Local); err == nil { - query = query.Where("sessions.created_at < ?", t.Add(24*time.Hour)) - } - } - if search != "" { - like := "%" + search + "%" - query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id"). - Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like) - } - - query.Count(&total) - if err := query.Order("sessions.created_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&sessions).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询会话失败"}) + items, total, err := service.ListSessions(sessionActor(c), middleware.GetTenantID(c), f) + if err != nil { + respondServiceError(c, err) return } - - // 批量补全客户 / 客服 / 渠道 / 消息数 - customerIDs := make([]uint, 0) - agentIDs := make([]uint, 0) - channelIDs := make([]uint, 0) - sessionIDs := make([]uint, 0, len(sessions)) - for _, s := range sessions { - sessionIDs = append(sessionIDs, s.ID) - customerIDs = append(customerIDs, s.CustomerID) - channelIDs = append(channelIDs, s.ChannelID) - if s.AgentID != nil { - agentIDs = append(agentIDs, *s.AgentID) - } - } - customerMap := map[uint]model.Customer{} - if len(customerIDs) > 0 { - var customers []model.Customer - model.DB.Where("id IN ?", uniqueUint(customerIDs)).Find(&customers) - for _, cu := range customers { - customerMap[cu.ID] = cu - } - } - agentMap := map[uint]model.User{} - if len(agentIDs) > 0 { - var users []model.User - model.DB.Where("id IN ?", uniqueUint(agentIDs)).Find(&users) - for _, u := range users { - agentMap[u.ID] = u - } - } - channelMap := map[uint]model.Channel{} - if len(channelIDs) > 0 { - var channels []model.Channel - model.DB.Where("id IN ?", uniqueUint(channelIDs)).Find(&channels) - for _, ch := range channels { - channelMap[ch.ID] = ch - } - } - msgCountMap := map[uint]int{} - if len(sessionIDs) > 0 { - type row struct { - SessionID uint - Cnt int - } - var rows []row - model.DB.Model(&model.Message{}). - Select("session_id, COUNT(*) as cnt"). - Where("session_id IN ?", sessionIDs). - Group("session_id"). - Scan(&rows) - for _, r := range rows { - msgCountMap[r.SessionID] = r.Cnt - } - } - - items := make([]SessionListItem, 0, len(sessions)) - for _, session := range sessions { - item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} - if !middleware.CanAccessAllData(c, "session") && session.AgentID != nil && *session.AgentID == middleware.GetUserID(c) { - item.UnreadCount = unreadCount(session) - } - if cu, ok := customerMap[session.CustomerID]; ok { - item.CustomerName = cu.Name - } - if session.AgentID != nil { - if u, ok := agentMap[*session.AgentID]; ok { - item.AgentName = u.Nickname - } - } - if ch, ok := channelMap[session.ChannelID]; ok { - item.ChannelName = ch.Name - item.ChannelType = ch.Type - } - var lastMsg model.Message - if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil { - if lastMsg.Type == "image" { - item.LastMessage = "[图片]" - } else { - item.LastMessage = lastMsg.Content - if utf8.RuneCountInString(item.LastMessage) > 40 { - runes := []rune(item.LastMessage) - item.LastMessage = string(runes[:40]) + "…" - } - } - t := lastMsg.SentAt - item.LastMessageAt = &t - } - items = append(items, item) - } - middleware.JSONList(c, items, total, page, pageSize) } -func uniqueUint(ids []uint) []uint { - seen := make(map[uint]struct{}, len(ids)) - out := make([]uint, 0, len(ids)) - for _, id := range ids { - if id == 0 { - continue - } - if _, ok := seen[id]; ok { - continue - } - seen[id] = struct{}{} - out = append(out, id) - } - return out -} - func (h *SessionHandler) Get(c *gin.Context) { - id := c.Param("id") - - session, ok := loadTenantSession(c, id) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canReadSession(c, session) { + if !service.CanReadSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该会话"}) return } - - var messages []model.Message - if err := model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) + detail, err := service.SessionDetail(session) + if err != nil { + respondServiceError(c, err) return } - var events []model.SessionEvent - model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events) - var pageViews []model.VisitorPageView - // 最新在前,坐席侧无需滚到底 - model.DB.Where("session_id = ?", session.ID).Order("entered_at desc").Limit(maxPageViewsPerSession).Find(&pageViews) - var pendingCount int64 - model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) - - maxSeq := 0 - for _, m := range messages { - if m.Seq > maxSeq { - maxSeq = m.Seq - } - } middleware.JSON(c, gin.H{ "session": session, - "messages": messages, - "events": events, - "page_views": pageViews, - "pending_count": pendingCount, - "max_seq": maxSeq, + "messages": detail.Messages, + "events": detail.Events, + "page_views": detail.PageViews, + "pending_count": detail.PendingCount, + "max_seq": detail.MaxSeq, }) } // ListMessages 按 seq 增量拉取消息:after_seq 之后的新消息(重连 catch-up)。 // GET /sessions/:id/messages?after_seq=12 func (h *SessionHandler) ListMessages(c *gin.Context) { - session, ok := loadTenantSession(c, c.Param("id")) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canReadSession(c, session) { + if !service.CanReadSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该会话消息"}) return } @@ -430,65 +160,44 @@ func (h *SessionHandler) ListMessages(c *gin.Context) { } } - query := model.DB.Where("session_id = ?", session.ID) - if afterSeq > 0 { - query = query.Where("seq > ?", afterSeq) - } - - var messages []model.Message - if err := query.Order("seq asc").Limit(limit).Find(&messages).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) + page, err := service.SessionMessagePage(session.ID, afterSeq, limit) + if err != nil { + respondServiceError(c, err) return } - - var maxSeq int - if err := model.DB.Model(&model.Message{}). - Where("session_id = ?", session.ID). - Select("COALESCE(MAX(seq), 0)"). - Scan(&maxSeq).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) - return - } - middleware.JSON(c, gin.H{ - "messages": messages, - "after_seq": afterSeq, - "max_seq": maxSeq, - "has_more": len(messages) >= limit && (len(messages) == 0 || messages[len(messages)-1].Seq < maxSeq), + "messages": page.Messages, + "after_seq": page.AfterSeq, + "max_seq": page.MaxSeq, + "has_more": page.HasMore, }) } func (h *SessionHandler) MarkRead(c *gin.Context) { - session, ok := loadTenantSession(c, c.Param("id")) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canReadSession(c, session) { + if !service.CanReadSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权标记该会话已读"}) return } - if session.AgentID == nil || middleware.CanAccessAllData(c, "session") || *session.AgentID != middleware.GetUserID(c) { - middleware.JSON(c, gin.H{"last_read_seq": session.LastReadSeq}) + lastReadSeq, err := service.MarkSessionRead(session, sessionActor(c)) + if err != nil { + respondServiceError(c, err) return } - var maxSeq int - if err := model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"}) - return - } - if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", maxSeq).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "标记已读失败"}) - return - } - middleware.JSON(c, gin.H{"last_read_seq": maxSeq}) + middleware.JSON(c, gin.H{"last_read_seq": lastReadSeq}) } func (h *SessionHandler) AddNote(c *gin.Context) { - session, ok := loadTenantSession(c, c.Param("id")) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canOperateSession(c, session) { + if !service.CanOperateSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权添加内部备注"}) return } @@ -497,58 +206,28 @@ func (h *SessionHandler) AddNote(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - content := strings.TrimSpace(req.Content) - if content == "" || utf8.RuneCountInString(content) > 500 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "内部备注需为 1 至 500 字"}) - return - } - event := model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "note", Detail: content} - if err := model.DB.Create(&event).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存内部备注失败"}) + event, err := service.AddSessionNote(session, middleware.GetUserID(c), req.Content) + if err != nil { + respondServiceError(c, err) return } middleware.JSON(c, event) } func (h *SessionHandler) ListAvailableAgents(c *gin.Context) { - type agentItem struct { - ID uint `json:"id"` - Nickname string `json:"nickname"` - Status string `json:"status"` // online | busy | offline | disabled - Role string `json:"role,omitempty"` - } // all=1 返回租户全部坐席(含离线),用于对话记录筛选; // 默认:可转接坐席(role=agent,在线/忙碌),便于前端展示状态 - tenantID := middleware.GetTenantID(c) - q := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, assignableRoleCodes(tenantID)) - if c.Query("all") != "1" { - q = q.Where("status IN ?", []string{"online", "busy"}) - } - var users []model.User - if err := q.Order("nickname asc").Find(&users).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询客服失败"}) + items, err := service.ListAvailableAgents(middleware.GetTenantID(c), c.Query("all") == "1") + if err != nil { + respondServiceError(c, err) return } - // 在线优先,其次忙碌,再按昵称 - statusRank := map[string]int{"online": 0, "busy": 1, "offline": 2, "disabled": 3} - sort.SliceStable(users, func(i, j int) bool { - ri, rj := statusRank[users[i].Status], statusRank[users[j].Status] - if ri != rj { - return ri < rj - } - return users[i].Nickname < users[j].Nickname - }) - items := make([]agentItem, 0, len(users)) - for _, user := range users { - items = append(items, agentItem{ - ID: user.ID, Nickname: user.Nickname, Status: user.Status, Role: user.Role, - }) - } middleware.JSON(c, items) } func (h *SessionHandler) Create(c *gin.Context) { - if !isTenantManager(c) { + actor := sessionActor(c) + if !actor.SessionAll { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可创建会话"}) return } @@ -557,45 +236,18 @@ func (h *SessionHandler) Create(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - - tenantID := middleware.GetTenantID(c) - var channel model.Channel - if err := model.DB.Where("id = ? AND tenant_id = ? AND status = ?", req.ChannelID, tenantID, "enabled").First(&channel).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道不存在或未启用"}) + session, err := service.CreateSession(middleware.GetTenantID(c), req.ChannelID, req.CustomerID, req.Priority) + if err != nil { + respondServiceError(c, err) return } - var customer model.Customer - if err := model.DB.Where("id = ? AND tenant_id = ?", req.CustomerID, tenantID).First(&customer).Error; err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户不存在"}) - return - } - - session := model.Session{ - TenantID: tenantID, - ChannelID: req.ChannelID, - CustomerID: req.CustomerID, - Priority: req.Priority, - Status: "waiting", - } - - if session.Priority == "" { - session.Priority = "normal" - } - - if err := model.DB.Create(&session).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) - return - } - model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, tenantID). - Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) - middleware.JSON(c, session) } func (h *SessionHandler) Assign(c *gin.Context) { - id := c.Param("id") - session, ok := loadTenantSession(c, id) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } var req AssignSessionReq @@ -603,81 +255,21 @@ func (h *SessionHandler) Assign(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - - if session.Status != "waiting" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"}) + detail, err := service.AssignSession(session, sessionActor(c), middleware.HasPermission(c, "session.transfer"), req.AgentID) + if err != nil { + respondServiceError(c, err) return } - if !middleware.HasPermission(c, "session.transfer") { - req.AgentID = middleware.GetUserID(c) - } else if !isTenantManager(c) && req.AgentID != middleware.GetUserID(c) { - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权分配会话"}) - return - } - if req.AgentID == 0 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择目标客服"}) - return - } - if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) - return - } - - result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting"). - Updates(map[string]interface{}{"agent_id": req.AgentID, "status": "active", "last_read_seq": 0}) - - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "分配失败"}) - return - } - if result.RowsAffected == 0 { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已被分配"}) - return - } - - operatorID := middleware.GetUserID(c) - operatorName := userDisplayName(operatorID) - targetName := userDisplayName(req.AgentID) - detail := "会话分配给 " + targetName - if operatorID == req.AgentID { - detail = operatorName + " 领取了会话" - } else { - detail = operatorName + " 将会话分配给 " + targetName - } - model.DB.Create(&model.SessionEvent{ - SessionID: session.ID, - OperatorID: operatorID, - Action: "assign", - Detail: detail, - }) - session.AgentID = &req.AgentID - session.Status = "active" - broadcastSessionUpdate(session) middleware.JSON(c, gin.H{"message": "分配成功", "detail": detail}) } -func userDisplayName(userID uint) string { - var user model.User - if err := model.DB.Select("id", "nickname", "username").First(&user, userID).Error; err != nil { - return "未知坐席" - } - if strings.TrimSpace(user.Nickname) != "" { - return user.Nickname - } - if strings.TrimSpace(user.Username) != "" { - return user.Username - } - return "未知坐席" -} - func (h *SessionHandler) Transfer(c *gin.Context) { - id := c.Param("id") - session, ok := loadTenantSession(c, id) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canOperateSession(c, session) { + if !service.CanOperateSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权转接该会话"}) return } @@ -686,123 +278,45 @@ func (h *SessionHandler) Transfer(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - - if err := loadAssignableAgent(session.TenantID, req.AgentID); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) + detail, err := service.TransferSession(session, sessionActor(c), req.AgentID) + if err != nil { + respondServiceError(c, err) return } - if session.AgentID != nil && *session.AgentID == req.AgentID { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能转接给当前接待坐席"}) - return - } - - operatorID := middleware.GetUserID(c) - operatorName := userDisplayName(operatorID) - targetName := userDisplayName(req.AgentID) - fromName := "" - if session.AgentID != nil { - fromName = userDisplayName(*session.AgentID) - } - - result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). - Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0}) - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "转接失败"}) - return - } - if result.RowsAffected == 0 { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话不可转接"}) - return - } - - // 详情写明「谁 → 谁」,便于接手坐席在备注/时间线中一眼看清 - detail := operatorName + " 转接给 " + targetName - if fromName != "" && fromName != operatorName { - detail = operatorName + " 将会话由 " + fromName + " 转接给 " + targetName - } - - model.DB.Create(&model.SessionEvent{ - SessionID: session.ID, - OperatorID: operatorID, - Action: "transfer", - Detail: detail, - }) - session.AgentID = &req.AgentID - broadcastSessionUpdate(session) - middleware.JSON(c, gin.H{"message": "转接成功", "detail": detail}) } func (h *SessionHandler) End(c *gin.Context) { - id := c.Param("id") - reason := c.Query("reason") - session, ok := loadTenantSession(c, id) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canOperateSession(c, session) { + if !service.CanOperateSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权结束该会话"}) return } - if reason == "" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请填写结束原因"}) + if err := service.EndSession(session, middleware.GetUserID(c), c.Query("reason")); err != nil { + respondServiceError(c, err) return } - if reason != "resolved" && reason != "no_response" && reason != "visitor_left" && reason != "transferred" && reason != "other" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "结束原因无效"}) - return - } - now := time.Now() - - result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended"). - Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now}) - - if result.RowsAffected == 0 { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) - return - } - - model.DB.Create(&model.SessionEvent{ - SessionID: session.ID, - OperatorID: middleware.GetUserID(c), - Action: "end", - Detail: "结束会话: " + reason, - }) - session.Status = "ended" - session.EndedAt = &now - broadcastSessionUpdate(session) - middleware.JSON(c, gin.H{"message": "已结束"}) } func (h *SessionHandler) UpdatePriority(c *gin.Context) { - id := c.Param("id") - priority := c.Query("priority") - session, ok := loadTenantSession(c, id) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if !canOperateSession(c, session) { + if !service.CanOperateSession(sessionActor(c), session) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权更新该会话"}) return } - if priority != "urgent" && priority != "normal" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "优先级无效"}) + if err := service.UpdateSessionPriority(session, sessionActor(c), c.Query("priority")); err != nil { + respondServiceError(c, err) return } - - result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ?", session.ID, session.TenantID). - Update("priority", priority) - if result.Error != nil || result.RowsAffected == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) - return - } - session.Priority = priority - broadcastSessionUpdate(session) - middleware.JSON(c, gin.H{"message": "已更新"}) } @@ -812,30 +326,17 @@ func (h *SessionHandler) Archive(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可归档"}) return } - session, ok := loadTenantSession(c, c.Param("id")) - if !ok { + session, err := service.LoadTenantSession(c.Param("id"), middleware.GetTenantID(c)) + if err != nil { + respondServiceError(c, err) return } - if session.Status != "ended" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "仅已结束的会话可归档"}) + archived, err := service.ArchiveSession(session, middleware.GetUserID(c)) + if err != nil { + respondServiceError(c, err) return } - result := model.DB.Model(&model.Session{}). - Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "ended"). - Update("status", "archived") - if result.Error != nil || result.RowsAffected == 0 { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "归档失败"}) - return - } - model.DB.Create(&model.SessionEvent{ - SessionID: session.ID, - OperatorID: middleware.GetUserID(c), - Action: "archive", - Detail: "会话已归档", - }) - session.Status = "archived" - broadcastSessionUpdate(session) - middleware.JSON(c, gin.H{"message": "已归档", "session": session}) + middleware.JSON(c, gin.H{"message": "已归档", "session": archived}) } // BatchArchive 批量归档已结束会话 @@ -847,29 +348,14 @@ func (h *SessionHandler) BatchArchive(c *gin.Context) { var req struct { IDs []uint `json:"ids" binding:"required"` } - if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 { + if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请选择要归档的会话"}) return } - if len(req.IDs) > 100 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "单次最多归档 100 条"}) + count, err := service.BatchArchiveSessions(middleware.GetTenantID(c), middleware.GetUserID(c), req.IDs) + if err != nil { + respondServiceError(c, err) return } - tenantID := middleware.GetTenantID(c) - result := model.DB.Model(&model.Session{}). - Where("tenant_id = ? AND status = ? AND id IN ?", tenantID, "ended", req.IDs). - Update("status", "archived") - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "批量归档失败"}) - return - } - for _, id := range req.IDs { - model.DB.Create(&model.SessionEvent{ - SessionID: id, - OperatorID: middleware.GetUserID(c), - Action: "archive", - Detail: "会话已归档", - }) - } - middleware.JSON(c, gin.H{"message": "已归档", "count": result.RowsAffected}) + middleware.JSON(c, gin.H{"message": "已归档", "count": count}) } diff --git a/server/internal/handler/settings.go b/server/internal/handler/settings.go index 790c038..ac1e7d4 100644 --- a/server/internal/handler/settings.go +++ b/server/internal/handler/settings.go @@ -1,361 +1,29 @@ package handler import ( - "encoding/json" - "fmt" "net/http" - "strings" - "time" - "unicode/utf8" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" - "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" ) type SettingsHandler struct{} func NewSettingsHandler() *SettingsHandler { return &SettingsHandler{} } -const ( - defaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?" - defaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" - defaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。" - defaultAgentNickname = "在线客服" - defaultTimezone = "Asia/Shanghai" - - maxWelcomeSegments = 10 - maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计) - maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限 -) - -// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。 -func stripHTMLTags(s string) string { - var b strings.Builder - inTag := false - for _, r := range s { - switch { - case r == '<': - inTag = true - case r == '>': - inTag = false - case !inTag: - b.WriteRune(r) - } - } - // 简单实体 - out := b.String() - out = strings.ReplaceAll(out, " ", " ") - out = strings.ReplaceAll(out, "&", "&") - out = strings.ReplaceAll(out, "<", "<") - out = strings.ReplaceAll(out, ">", ">") - return strings.TrimSpace(out) -} - -// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。 -type WelcomeSegment struct { - Type string `json:"type"` // text | image - Content string `json:"content"` -} - -var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} - -func defaultWelcomeSegments() []WelcomeSegment { - return []WelcomeSegment{{Type: "text", Content: defaultWelcomeMessage}} -} - -func defaultWelcomeMessagesJSON() string { - b, _ := json.Marshal(defaultWelcomeSegments()) - return string(b) -} - -func firstWelcomeText(segs []WelcomeSegment) string { - for _, s := range segs { - if s.Type != "text" { - continue - } - plain := stripHTMLTags(s.Content) - if plain == "" { - plain = strings.TrimSpace(s.Content) - } - if plain == "" { - continue - } - // welcome_message 列 size:500 - if utf8.RuneCountInString(plain) > 500 { - return string([]rune(plain)[:500]) - } - return plain - } - return defaultWelcomeMessage -} - -// resolveWelcomeSegments 解析多段欢迎语;兼容仅有旧 welcome_message 的数据。 -func resolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment { - if setting == nil { - return defaultWelcomeSegments() - } - raw := strings.TrimSpace(setting.WelcomeMessagesJSON) - if raw != "" { - var segs []WelcomeSegment - if err := json.Unmarshal([]byte(raw), &segs); err == nil { - out := make([]WelcomeSegment, 0, len(segs)) - for _, s := range segs { - t := strings.TrimSpace(s.Type) - c := strings.TrimSpace(s.Content) - if (t == "text" || t == "image") && c != "" { - out = append(out, WelcomeSegment{Type: t, Content: c}) - } - } - if len(out) > 0 { - return out - } - } - } - if msg := strings.TrimSpace(setting.WelcomeMessage); msg != "" { - return []WelcomeSegment{{Type: "text", Content: msg}} - } - return defaultWelcomeSegments() -} - -// normalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。 -func normalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) { - if len(input) == 0 { - return nil, "", fmt.Errorf("至少保留一段欢迎语") - } - if len(input) > maxWelcomeSegments { - return nil, "", fmt.Errorf("欢迎语最多 %d 段", maxWelcomeSegments) - } - out := make([]WelcomeSegment, 0, len(input)) - for i, seg := range input { - t := strings.ToLower(strings.TrimSpace(seg.Type)) - c := strings.TrimSpace(seg.Content) - switch t { - case "text": - // 允许 HTML 富文本;禁止明显脚本 - lower := strings.ToLower(c) - if strings.Contains(lower, " maxWelcomeTextLen { - return nil, "", fmt.Errorf("第 %d 段文字不能超过 %d 字", i+1, maxWelcomeTextLen) - } - if utf8.RuneCountInString(c) > maxWelcomeHTMLLen { - return nil, "", fmt.Errorf("第 %d 段内容过长", i+1) - } - out = append(out, WelcomeSegment{Type: "text", Content: c}) - case "image": - validated, err := validateMessageContent("image", c) - if err != nil { - return nil, "", fmt.Errorf("第 %d 段图片:%s", i+1, err.Error()) - } - out = append(out, WelcomeSegment{Type: "image", Content: validated}) - default: - return nil, "", fmt.Errorf("第 %d 段类型无效,请使用 text 或 image", i+1) - } - } - return out, firstWelcomeText(out), nil -} - -func defaultWorkHoursJSON() string { - // 默认全周全天可接待,租户可在设置中收紧为工作日时段 - hours := map[string]string{ - "monday": "全天", "tuesday": "全天", "wednesday": "全天", - "thursday": "全天", "friday": "全天", "saturday": "全天", "sunday": "全天", - } - b, _ := json.Marshal(hours) - return string(b) -} - -func getOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) { - var setting model.TenantSetting - err := model.DB.Where("tenant_id = ?", tenantID).First(&setting).Error - if err == nil { - return &setting, nil - } - - var tenant model.Tenant - _ = model.DB.First(&tenant, tenantID).Error - displayName := tenant.Name - if displayName == "" { - displayName = "在线客服" - } - setting = model.TenantSetting{ - TenantID: tenantID, - DisplayName: displayName, - AgentNickname: defaultAgentNickname, - Timezone: defaultTimezone, - WelcomeMessage: defaultWelcomeMessage, - WelcomeMessagesJSON: defaultWelcomeMessagesJSON(), - OfflinePrompt: defaultOfflinePrompt, - WorkHoursJSON: defaultWorkHoursJSON(), - WorktimePrompt: defaultWorktimePrompt, - NotifyNewSession: true, - NotifyOfflineLeave: true, - NotifyDailyReport: false, - AssignStrategy: assignStrategyLeastLoad, - MaxActivePerAgent: 0, - } - if err := model.DB.Create(&setting).Error; err != nil { - return nil, err - } - return &setting, nil -} - -func parseWorkHours(raw string) map[string]string { - result := map[string]string{} - if strings.TrimSpace(raw) == "" { - _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) - return result - } - if err := json.Unmarshal([]byte(raw), &result); err != nil { - _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) - } - return result -} - -// normalizeWorkHourSlot 规范化单日工作时间配置。 -// 允许:空/休息(→ 空)、全天、HH:MM-HH:MM(支持跨午夜,如 22:00-06:00)。 -func normalizeWorkHourSlot(val string) (string, error) { - val = strings.TrimSpace(val) - if val == "" || val == "休息" { - return "", nil - } - if val == "全天" { - return "全天", nil - } - // 仅允许一个区间:HH:MM-HH:MM - parts := strings.Split(val, "-") - if len(parts) != 2 { - return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00 / 全天 / 空") - } - startS := strings.TrimSpace(parts[0]) - endS := strings.TrimSpace(parts[1]) - start, err1 := time.Parse("15:04", startS) - end, err2 := time.Parse("15:04", endS) - if err1 != nil || err2 != nil { - return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00(24 小时制)") - } - return start.Format("15:04") + "-" + end.Format("15:04"), nil -} - -// slotCoversLocal 判断 slot(全天 / HH:MM-HH:MM)是否覆盖 local 时刻。 -func slotCoversLocal(slot string, local time.Time, loc *time.Location) bool { - slot = strings.TrimSpace(slot) - if slot == "" || slot == "休息" { - return false - } - if slot == "全天" { - return true - } - parts := strings.Split(slot, "-") - if len(parts) != 2 { - // 非法配置 fail-open,避免因脏数据整站拒接 - return true - } - start, err1 := time.ParseInLocation("15:04", strings.TrimSpace(parts[0]), loc) - end, err2 := time.ParseInLocation("15:04", strings.TrimSpace(parts[1]), loc) - if err1 != nil || err2 != nil { - return true - } - startAt := time.Date(local.Year(), local.Month(), local.Day(), start.Hour(), start.Minute(), 0, 0, loc) - endAt := time.Date(local.Year(), local.Month(), local.Day(), end.Hour(), end.Minute(), 0, 0, loc) - if !endAt.After(startAt) { - // 跨午夜:当前 >= start 或 < end - return !local.Before(startAt) || local.Before(endAt) - } - return !local.Before(startAt) && local.Before(endAt) -} - -// isWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。 -// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM(可跨午夜)。 -func isWithinWorkHours(setting *model.TenantSetting, now time.Time) bool { - if setting == nil { - return true - } - loc, err := time.LoadLocation(setting.Timezone) - if err != nil || setting.Timezone == "" { - loc, _ = time.LoadLocation(defaultTimezone) - } - local := now.In(loc) - hours := parseWorkHours(setting.WorkHoursJSON) - var key string - switch local.Weekday() { - case time.Monday: - key = "monday" - case time.Tuesday: - key = "tuesday" - case time.Wednesday: - key = "wednesday" - case time.Thursday: - key = "thursday" - case time.Friday: - key = "friday" - case time.Saturday: - key = "saturday" - default: - key = "sunday" - } - return slotCoversLocal(hours[key], local, loc) -} - -func settingsResponse(setting *model.TenantSetting) gin.H { - strategy := normalizeAssignStrategy(setting.AssignStrategy) - maxActive := setting.MaxActivePerAgent - if maxActive < 0 { - maxActive = 0 - } - 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, - } -} - func (h *SettingsHandler) Get(c *gin.Context) { tenantID := middleware.GetTenantID(c) if tenantID == 0 { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可查看设置"}) return } - setting, err := getOrCreateTenantSettings(tenantID) + setting, err := service.GetOrCreateTenantSettings(tenantID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"}) return } - middleware.JSON(c, settingsResponse(setting)) -} - -type updateSettingsReq struct { - DisplayName *string `json:"display_name"` - AgentNickname *string `json:"agent_nickname"` - Timezone *string `json:"timezone"` - WelcomeMessage *string `json:"welcome_message"` - WelcomeMessages *[]WelcomeSegment `json:"welcome_messages"` - OfflinePrompt *string `json:"offline_prompt"` - WorkHours map[string]string `json:"work_hours"` - WorktimePrompt *string `json:"worktime_prompt"` - NotifyNewSession *bool `json:"notify_new_session"` - NotifyOfflineLeave *bool `json:"notify_offline_leave"` - NotifyDailyReport *bool `json:"notify_daily_report"` - AssignStrategy *string `json:"assign_strategy"` - MaxActivePerAgent *int `json:"max_active_per_agent"` + middleware.JSON(c, service.SettingsResponse(setting)) } func (h *SettingsHandler) Update(c *gin.Context) { @@ -368,173 +36,38 @@ func (h *SettingsHandler) Update(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅租户账号可修改设置"}) return } - setting, err := getOrCreateTenantSettings(tenantID) - if err != nil { + if _, err := service.GetOrCreateTenantSettings(tenantID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "加载设置失败"}) return } - var req updateSettingsReq + var req service.SettingsUpdateReq if err := c.ShouldBindJSON(&req); err != nil { 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.TouchesAssignRule() && !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.TouchesAutoReply() && !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.TouchesWorktime() && !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 { - name := strings.TrimSpace(*req.DisplayName) - if name == "" || len([]rune(name)) > 100 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "租户显示名称无效"}) - return - } - updates["display_name"] = name - // 同步更新租户主表名称(唯一约束下若冲突则仅更新 setting) - var conflict int64 - model.DB.Model(&model.Tenant{}).Where("name = ? AND id <> ?", name, tenantID).Count(&conflict) - if conflict == 0 { - model.DB.Model(&model.Tenant{}).Where("id = ?", tenantID).Update("name", name) - } - } - if req.AgentNickname != nil { - nick := strings.TrimSpace(*req.AgentNickname) - if len([]rune(nick)) > 50 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客服昵称过长"}) - return - } - updates["agent_nickname"] = nick - } - if req.Timezone != nil { - tz := strings.TrimSpace(*req.Timezone) - if tz == "" { - tz = defaultTimezone - } - if _, err := time.LoadLocation(tz); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "时区无效"}) - return - } - updates["timezone"] = tz - } - // 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本 - if req.WelcomeMessages != nil { - segs, firstText, err := normalizeWelcomeSegments(*req.WelcomeMessages) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) - return - } - b, err := json.Marshal(segs) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语序列化失败"}) - return - } - updates["welcome_messages_json"] = string(b) - updates["welcome_message"] = firstText - } else if req.WelcomeMessage != nil { - msg := strings.TrimSpace(*req.WelcomeMessage) - if len([]rune(msg)) > 500 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "欢迎语不能超过 500 字"}) - return - } - updates["welcome_message"] = msg - // 同步为单段,避免旧字段与 JSON 不一致 - if msg == "" { - updates["welcome_messages_json"] = defaultWelcomeMessagesJSON() - updates["welcome_message"] = defaultWelcomeMessage - } else { - b, _ := json.Marshal([]WelcomeSegment{{Type: "text", Content: msg}}) - updates["welcome_messages_json"] = string(b) - } - } - if req.OfflinePrompt != nil { - msg := strings.TrimSpace(*req.OfflinePrompt) - if len([]rune(msg)) > 500 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "离线提示不能超过 500 字"}) - return - } - updates["offline_prompt"] = msg - } - if req.WorktimePrompt != nil { - msg := strings.TrimSpace(*req.WorktimePrompt) - if len([]rune(msg)) > 500 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "非工作时间提示不能超过 500 字"}) - return - } - updates["worktime_prompt"] = msg - } - if req.WorkHours != nil { - normalized := map[string]string{} - for _, key := range weekdayKeys { - slot, err := normalizeWorkHourSlot(req.WorkHours[key]) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) - return - } - normalized[key] = slot - } - b, err := json.Marshal(normalized) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "工作时间序列化失败"}) - return - } - updates["work_hours_json"] = string(b) - } - if req.NotifyNewSession != nil { - updates["notify_new_session"] = *req.NotifyNewSession - } - if req.NotifyOfflineLeave != nil { - updates["notify_offline_leave"] = *req.NotifyOfflineLeave - } - if req.NotifyDailyReport != nil { - updates["notify_daily_report"] = *req.NotifyDailyReport - } - if req.AssignStrategy != nil { - raw := strings.TrimSpace(*req.AssignStrategy) - if raw != assignStrategyLeastLoad && raw != assignStrategyRoundRobin { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "分配策略无效,可选 least_load / round_robin"}) - return - } - updates["assign_strategy"] = raw - } - if req.MaxActivePerAgent != nil { - if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "最大并发需为 0–200(0 表示不限制)"}) - return - } - updates["max_active_per_agent"] = *req.MaxActivePerAgent - } - if len(updates) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) + if req.TouchesNotification() && !middleware.HasPermission(c, "settings.notification") { + c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权修改通知设置"}) return } - if err := model.DB.Model(setting).Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "保存失败"}) + resp, err := service.UpdateTenantSettings(tenantID, &req) + if err != nil { + respondServiceError(c, err) return } - _ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error - middleware.JSON(c, settingsResponse(setting)) + middleware.JSON(c, resp) } diff --git a/server/internal/handler/settings_welcome_test.go b/server/internal/handler/settings_welcome_test.go deleted file mode 100644 index cd8697e..0000000 --- a/server/internal/handler/settings_welcome_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package handler - -import ( - "testing" - - "kefu-cloud/server/internal/model" -) - -func TestNormalizeWelcomeSegments(t *testing.T) { - SetImagePublicBase("http://localhost:9100/kefu") - - _, _, err := normalizeWelcomeSegments(nil) - if err == nil { - t.Fatal("空列表应失败") - } - - ok, first, err := normalizeWelcomeSegments([]WelcomeSegment{ - {Type: "text", Content: " 你好 "}, - {Type: "image", Content: "http://localhost:9100/kefu/tenants/1/welcome/a.webp"}, - {Type: "text", Content: "请描述问题"}, - }) - if err != nil { - t.Fatalf("合法多段应通过: %v", err) - } - if len(ok) != 3 || ok[0].Content != "你好" || first != "你好" { - t.Fatalf("规范化结果异常: %+v first=%s", ok, first) - } - - _, _, err = normalizeWelcomeSegments([]WelcomeSegment{ - {Type: "image", Content: "https://evil.example/x.png"}, - }) - if err == nil { - t.Fatal("非白名单图片 URL 应失败") - } - - tooMany := make([]WelcomeSegment, maxWelcomeSegments+1) - for i := range tooMany { - tooMany[i] = WelcomeSegment{Type: "text", Content: "x"} - } - if _, _, err := normalizeWelcomeSegments(tooMany); err == nil { - t.Fatal("超过段数上限应失败") - } -} - -func TestResolveWelcomeSegmentsCompat(t *testing.T) { - // 仅有旧字段 - s1 := &model.TenantSetting{WelcomeMessage: "旧欢迎语"} - segs := resolveWelcomeSegments(s1) - if len(segs) != 1 || segs[0].Type != "text" || segs[0].Content != "旧欢迎语" { - t.Fatalf("旧字段兼容失败: %+v", segs) - } - - // 优先 JSON - s2 := &model.TenantSetting{ - WelcomeMessage: "旧", - WelcomeMessagesJSON: `[{"type":"text","content":"新1"},{"type":"image","content":"http://x/a.webp"}]`, - } - segs = resolveWelcomeSegments(s2) - if len(segs) != 2 || segs[0].Content != "新1" { - t.Fatalf("JSON 优先失败: %+v", segs) - } - - // 空 → 默认 - segs = resolveWelcomeSegments(&model.TenantSetting{}) - if len(segs) != 1 || segs[0].Content != defaultWelcomeMessage { - t.Fatalf("默认欢迎语失败: %+v", segs) - } -} diff --git a/server/internal/handler/upload.go b/server/internal/handler/upload.go index 260b148..87acd5b 100644 --- a/server/internal/handler/upload.go +++ b/server/internal/handler/upload.go @@ -12,12 +12,13 @@ import ( "kefu-cloud/server/internal/config" "kefu-cloud/server/internal/media" "kefu-cloud/server/internal/middleware" + "kefu-cloud/server/internal/service" "kefu-cloud/server/internal/storage" ) type UploadHandler struct { - store storage.ObjectStorage - cfg config.StorageConfig + store storage.ObjectStorage + cfg config.StorageConfig } func NewUploadHandler(store storage.ObjectStorage, cfg config.StorageConfig) *UploadHandler { @@ -45,8 +46,9 @@ func (h *UploadHandler) WidgetUploadImage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) return } - session, ok := loadVisitorSession(c, sid, token) - if !ok { + session, err := service.WidgetSession(sid, token) + if err != nil { + respondServiceError(c, err) return } if session.Status == "ended" || session.Status == "archived" { diff --git a/server/internal/handler/widget.go b/server/internal/handler/widget.go index 607b7e5..038ac68 100644 --- a/server/internal/handler/widget.go +++ b/server/internal/handler/widget.go @@ -1,17 +1,13 @@ package handler import ( - "errors" "net/http" - "regexp" "strconv" "strings" - "time" "github.com/gin-gonic/gin" - "gorm.io/gorm" "kefu-cloud/server/internal/middleware" - "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/service" "kefu-cloud/server/internal/ws" ) @@ -65,10 +61,6 @@ type WidgetLeaveReq struct { VisitorToken string `json:"visitor_token"` } -var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`) -var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) -var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) - func (h *WidgetHandler) Init(c *gin.Context) { var req WidgetInitReq if err := c.ShouldBindQuery(&req); err != nil { @@ -84,185 +76,39 @@ func (h *WidgetHandler) Init(c *gin.Context) { if req.ChannelKey == "" { req.ChannelKey = c.Query("channel_key") } - if !channelKeyPattern.MatchString(req.ChannelKey) { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "渠道标识无效"}) - return - } - var channel model.Channel - if err := model.DB. - Where("type = ? AND status = ? AND script_code LIKE ?", "web", "enabled", "%data-id=\""+req.ChannelKey+"\"%"). - First(&channel).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "渠道不存在或已关闭"}) - return - } - - name := strings.TrimSpace(req.VisitorName) - if name == "" { - name = "访客" - } - - // 创建或查找客户 - var customer model.Customer - model.DB.Where("tenant_id = ? AND name = ? AND phone = ''", channel.TenantID, name).First(&customer) - if customer.ID == 0 { - customer = model.Customer{ - TenantID: channel.TenantID, - Name: name, - Source: "网页", - Status: "online", - } - model.DB.Create(&customer) - } - - visitorToken, visitorTokenHash, err := model.NewVisitorToken() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建访客会话失败"}) - return - } - - visitorIP, visitorRegion, userAgent, _ := captureVisitorMeta(c) - deviceKey := normalizeDeviceKey(req.DeviceID, userAgent) - // 黑名单拦截(IP / 设备) - if blocked, blKind, blReason := checkVisitorBlacklist(channel.TenantID, visitorIP, deviceKey); blocked { - kindLabel := "IP" - if blKind == "device" { - kindLabel = "设备" - } - msg := "您暂时无法使用在线客服" - if strings.TrimSpace(blReason) != "" { - msg = msg + "(" + kindLabel + "限制)" - } - c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": msg}) - return - } - pageURL := sanitizePageURL(req.PageURL) + // 宿主页信息:body 优先,query 兜底 + pageURL := service.SanitizePageURL(req.PageURL) if pageURL == "" { - // query 兜底 - pageURL = sanitizePageURL(c.Query("page_url")) + pageURL = service.SanitizePageURL(c.Query("page_url")) } - pageTitle := sanitizePageTitle(req.PageTitle) + pageTitle := service.SanitizePageTitle(req.PageTitle) if pageTitle == "" { - pageTitle = sanitizePageTitle(c.Query("page_title")) + pageTitle = service.SanitizePageTitle(c.Query("page_title")) } - referrer := sanitizePageURL(req.Referrer) + referrer := service.SanitizePageURL(req.Referrer) if referrer == "" { - referrer = sanitizePageURL(c.Query("referrer")) + referrer = service.SanitizePageURL(c.Query("referrer")) } - now := time.Now() - // 创建会话 - session := model.Session{ - TenantID: channel.TenantID, - ChannelID: channel.ID, - CustomerID: customer.ID, - VisitorTokenHash: visitorTokenHash, - VisitorIP: visitorIP, - VisitorRegion: visitorRegion, - UserAgent: userAgent, - DeviceKey: deviceKey, - LandingURL: pageURL, - LandingTitle: pageTitle, - Referrer: referrer, - CurrentURL: pageURL, - CurrentTitle: pageTitle, - LastSeenAt: &now, - Status: "waiting", - Priority: "normal", - } - if err := model.DB.Create(&session).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建会话失败"}) - return - } - // 首条浏览轨迹 - if pageURL != "" { - _ = model.DB.Create(&model.VisitorPageView{ - SessionID: session.ID, - TenantID: channel.TenantID, - URL: pageURL, - Title: pageTitle, - EnteredAt: now, - }).Error - } - model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID). - Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) - - onlineCount, err := countOnlineAgents(channel.TenantID) + ip, region, userAgent, _ := captureVisitorMeta(c) + resp, err := service.WidgetInit(service.WidgetInitInput{ + ChannelKey: req.ChannelKey, + VisitorName: req.VisitorName, + DeviceID: req.DeviceID, + PageURL: pageURL, + PageTitle: pageTitle, + Referrer: referrer, + Meta: service.VisitorMeta{ + IP: ip, + Region: region, + UserAgent: userAgent, + }, + }) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"}) + respondServiceError(c, err) return } - - setting, _ := getOrCreateTenantSettings(channel.TenantID) - withinWorkHours := isWithinWorkHours(setting, time.Now()) - // 非工作时间不自动分配,走留言/排队提示 - canServeNow := onlineCount > 0 && withinWorkHours - - var assignedAgent *model.User - if canServeNow { - assignedAgent, err = tryAutoAssign(&session) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "自动分配失败"}) - return - } - } - - if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } - - welcomeSegs := resolveWelcomeSegments(setting) - welcome := firstWelcomeText(welcomeSegs) - offlinePrompt := offlineLeavePrompt - agentNickname := defaultAgentNickname - displayName := "在线客服" - worktimePrompt := defaultWorktimePrompt - if setting != nil { - if setting.OfflinePrompt != "" { - offlinePrompt = setting.OfflinePrompt - } - if setting.AgentNickname != "" { - agentNickname = setting.AgentNickname - } - if setting.DisplayName != "" { - displayName = setting.DisplayName - } - if setting.WorktimePrompt != "" { - worktimePrompt = setting.WorktimePrompt - } - } - // 非工作时间优先展示非工作时间提示 - if !withinWorkHours { - offlinePrompt = worktimePrompt - } - - resp := gin.H{ - "session_id": session.ID, - "customer_id": customer.ID, - "channel_id": channel.ID, - "tenant_id": channel.TenantID, - "visitor_token": visitorToken, - "agents_online": canServeNow, - "online_agent_count": onlineCount, - "within_work_hours": withinWorkHours, - "session_status": session.Status, - "welcome_message": welcome, - "welcome_messages": welcomeSegs, - "display_name": displayName, - "agent_nickname": agentNickname, - } - if !canServeNow { - resp["offline_prompt"] = offlinePrompt - } - if assignedAgent != nil { - resp["agent_id"] = assignedAgent.ID - resp["agent_name"] = assignedAgent.Nickname - } else if canServeNow { - resp["agent_name"] = agentNickname - } - resp["landing_url"] = session.LandingURL - resp["current_url"] = session.CurrentURL - c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) } @@ -273,101 +119,17 @@ func (h *WidgetHandler) PageView(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - token := visitorTokenFromRequest(c, req.VisitorToken) - session, ok := loadVisitorSession(c, req.SessionID, token) - if !ok { + session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if err != nil { + respondServiceError(c, err) return } - if session.Status == "ended" || session.Status == "archived" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) + data, err := service.WidgetPageView(session, service.SanitizePageURL(req.PageURL), service.SanitizePageTitle(req.PageTitle)) + if err != nil { + respondServiceError(c, err) return } - - pageURL := sanitizePageURL(req.PageURL) - if pageURL == "" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "页面地址无效"}) - return - } - pageTitle := sanitizePageTitle(req.PageTitle) - now := time.Now() - - // 与当前页相同:只刷新活跃时间/标题,不插重复轨迹 - if strings.TrimSpace(session.CurrentURL) == pageURL { - updates := map[string]interface{}{ - "last_seen_at": now, - } - titleFixed := false - if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) { - updates["current_title"] = pageTitle - // 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title) - var last model.VisitorPageView - if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL). - Order("entered_at desc").First(&last).Error; err == nil { - _ = model.DB.Model(&last).Update("title", pageTitle).Error - titleFixed = true - if payload, err := ws.NewEvent("page_view", session.ID, gin.H{ - "id": last.ID, - "session_id": last.SessionID, - "url": pageURL, - "title": pageTitle, - "entered_at": last.EnteredAt, - "title_fix": true, - }); err == nil { - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } - } - } - _ = model.DB.Model(session).Updates(updates).Error - c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true, "title_updated": titleFixed}}) - return - } - - pv := model.VisitorPageView{ - SessionID: session.ID, - TenantID: session.TenantID, - URL: pageURL, - Title: pageTitle, - EnteredAt: now, - } - if err := model.DB.Create(&pv).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "记录失败"}) - return - } - _ = model.DB.Model(session).Updates(map[string]interface{}{ - "current_url": pageURL, - "current_title": pageTitle, - "last_seen_at": now, - }).Error - - // 控制单会话条数 - var count int64 - model.DB.Model(&model.VisitorPageView{}).Where("session_id = ?", session.ID).Count(&count) - if count > maxPageViewsPerSession { - var oldest []model.VisitorPageView - model.DB.Where("session_id = ?", session.ID).Order("entered_at asc"). - Limit(int(count - maxPageViewsPerSession)).Find(&oldest) - ids := make([]uint, 0, len(oldest)) - for _, o := range oldest { - ids = append(ids, o.ID) - } - if len(ids) > 0 { - model.DB.Where("id IN ?", ids).Delete(&model.VisitorPageView{}) - } - } - - if payload, err := ws.NewEvent("page_view", session.ID, gin.H{ - "id": pv.ID, - "session_id": pv.SessionID, - "url": pv.URL, - "title": pv.Title, - "entered_at": pv.EnteredAt, - }); err == nil { - // 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor, - // 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。 - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } - - c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv}) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": data}) } // Heartbeat POST /api/widget/heartbeat — 刷新 last_seen,供在线读秒 @@ -377,18 +139,21 @@ func (h *WidgetHandler) Heartbeat(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - token := visitorTokenFromRequest(c, req.VisitorToken) - session, ok := loadVisitorSession(c, req.SessionID, token) - if !ok { + session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if err != nil { + respondServiceError(c, err) return } - if session.Status == "ended" || session.Status == "archived" { + ended, lastSeen, err := service.WidgetHeartbeat(session) + if err != nil { + respondServiceError(c, err) + return + } + if ended { c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "ended": true}}) return } - now := time.Now() - _ = model.DB.Model(session).Update("last_seen_at", now).Error - c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "last_seen_at": now}}) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"ok": true, "last_seen_at": lastSeen}}) } func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { @@ -398,19 +163,6 @@ func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { return bodyToken } -func loadVisitorSession(c *gin.Context, sessionID uint, token string) (*model.Session, bool) { - var session model.Session - if err := model.DB.First(&session, sessionID).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "会话不存在"}) - return nil, false - } - if !model.VerifyVisitorToken(&session, token) { - c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "访客会话凭证无效"}) - return nil, false - } - return &session, true -} - func (h *WidgetHandler) SendMessage(c *gin.Context) { var req WidgetMessageReq if err := c.ShouldBindJSON(&req); err != nil { @@ -420,70 +172,22 @@ func (h *WidgetHandler) SendMessage(c *gin.Context) { if req.Type == "" { req.Type = "text" } - content, err := validateMessageContent(req.Type, req.Content) + // 先校验内容,再加载会话(与原校验顺序一致) + content, err := service.ValidateMessageContent(req.Type, req.Content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } - - session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) - if !ok { + session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if err != nil { + respondServiceError(c, err) return } - if session.Status == "ended" || session.Status == "archived" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"}) + msg, err := service.WidgetSendMessage(session, req.Type, content) + if err != nil { + respondServiceError(c, err) return } - - custID := session.CustomerID - msg := model.Message{ - SessionID: session.ID, - SenderType: "visitor", - SenderID: &custID, - Content: content, - Type: req.Type, - SentAt: time.Now(), - } - - if err := model.CreateMessage(&msg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "发送失败"}) - return - } - // 发送后清空草稿 - _ = model.DB.Model(session).Updates(map[string]interface{}{ - "draft_text": "", - "draft_updated_at": time.Now(), - }).Error - model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) - - // 从已发送消息再提取一次联系方式 - if req.Type == "text" { - go onVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content) - } - - // 仍在排队时尝试自动分配(客服刚上线的场景) - if session.Status == "waiting" && session.AgentID == nil { - if _, err := tryAutoAssign(session); err == nil { - _ = model.DB.First(session, session.ID) - } - } - - if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { - if session.AgentID == nil { - // 等待会话的消息要通知全部客服,便于任一在线客服及时领取。 - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } else { - ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) - } - } - // 通知坐席清空草稿预览 - if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{ - "from": "visitor", - "text": "", - }); err == nil { - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload) - } - c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) } @@ -494,7 +198,7 @@ func (h *WidgetHandler) LeaveMessage(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } - content, err := validateMessageContent("text", req.Content) + content, err := service.ValidateMessageContent("text", req.Content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return @@ -502,104 +206,27 @@ func (h *WidgetHandler) LeaveMessage(c *gin.Context) { name := strings.TrimSpace(req.Name) phone := strings.TrimSpace(req.Phone) email := strings.TrimSpace(req.Email) - if phone == "" && email == "" { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "请至少填写手机号或邮箱,便于我们回复"}) - return - } - if phone != "" && !phonePattern.MatchString(phone) { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "手机号格式不正确"}) - return - } - if email != "" && !emailPattern.MatchString(email) { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "邮箱格式不正确"}) - return - } - if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) { - c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "姓名需为 2 至 50 个字符"}) + if err := service.ValidateLeaveContact(name, phone, email); err != nil { + respondServiceError(c, err) return } - session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) - if !ok { + session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if err != nil { + respondServiceError(c, err) return } - if session.Status == "ended" || session.Status == "archived" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"}) + result, err := service.WidgetLeaveMessage(session, content, name, phone, email) + if err != nil { + respondServiceError(c, err) return } - - // 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库 - onlineCount, _ := countOnlineAgents(session.TenantID) - if onlineCount > 0 && session.Status == "waiting" { - if _, err := tryAutoAssign(session); err == nil { - _ = model.DB.First(session, session.ID) - } - } - - updates := map[string]interface{}{"last_contact_at": time.Now()} - if name != "" { - updates["name"] = name - } - if phone != "" { - updates["phone"] = phone - } - if email != "" { - updates["email"] = email - } - if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). - Updates(updates).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新联系方式失败"}) - return - } - - custID := session.CustomerID - msg := model.Message{ - SessionID: session.ID, - SenderType: "visitor", - SenderID: &custID, - Content: content, - Type: "text", - SentAt: time.Now(), - } - if err := model.CreateMessage(&msg); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "留言失败"}) - return - } - - contactBits := make([]string, 0, 3) - if name != "" { - contactBits = append(contactBits, "姓名:"+name) - } - if phone != "" { - contactBits = append(contactBits, "手机:"+phone) - } - if email != "" { - contactBits = append(contactBits, "邮箱:"+email) - } - model.DB.Create(&model.SessionEvent{ - SessionID: session.ID, - OperatorID: 0, - Action: "offline_leave", - Detail: "离线留言 · " + strings.Join(contactBits, " · "), - }) - - if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { - if session.AgentID == nil { - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } else { - ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) - } - } - if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { - ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) - } - c.JSON(http.StatusOK, gin.H{ "code": 0, "data": gin.H{ - "message": msg, - "session_status": session.Status, - "agents_online": onlineCount > 0, + "message": result.Message, + "session_status": result.SessionStatus, + "agents_online": result.AgentsOnline, }, }) } @@ -618,8 +245,9 @@ func (h *WidgetHandler) Connect(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) return } - session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromWebSocket(c)) - if !ok { + session, err := service.WidgetSession(uint(sessionID), visitorTokenFromWebSocket(c)) + if err != nil { + respondServiceError(c, err) return } client, err := ws.UpgradeVisitor(c.Writer, c.Request, session.TenantID, session.ID) @@ -640,42 +268,15 @@ func (h *WidgetHandler) SubmitRating(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "评分应为 1 至 5 星"}) return } - session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) - if !ok { + session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) + if err != nil { + respondServiceError(c, err) return } - if session.Status != "ended" { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话结束后才能评价"}) + if err := service.WidgetSubmitRating(session, req.Score, req.Text); err != nil { + respondServiceError(c, err) return } - if session.SatisfactionScore != nil { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"}) - return - } - - if err := model.DB.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.Session{}).Where("id = ? AND satisfaction_score IS NULL", session.ID). - Updates(map[string]interface{}{"satisfaction_score": req.Score, "satisfaction_text": req.Text}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). - Updates(map[string]interface{}{ - "satisfaction_sum": gorm.Expr("satisfaction_sum + ?", req.Score), - "satisfaction_count": gorm.Expr("satisfaction_count + 1"), - }).Error - }); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "该会话已评价"}) - return - } - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "提交评价失败"}) - return - } - middleware.JSON(c, gin.H{"message": "感谢您的评价"}) } @@ -685,8 +286,9 @@ func (h *WidgetHandler) GetMessages(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) return } - session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromRequest(c, "")) - if !ok { + session, err := service.WidgetSession(uint(sessionID), visitorTokenFromRequest(c, "")) + if err != nil { + respondServiceError(c, err) return } @@ -710,36 +312,10 @@ func (h *WidgetHandler) GetMessages(c *gin.Context) { } } - query := model.DB.Where("session_id = ?", sessionID) - if afterSeq > 0 { - query = query.Where("seq > ?", afterSeq) - } - - var messages []model.Message - if err := query.Order("seq asc").Limit(limit).Find(&messages).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) + data, err := service.WidgetMessages(session, afterSeq, limit) + if err != nil { + respondServiceError(c, err) return } - - var maxSeq int - if err := model.DB.Model(&model.Message{}). - Where("session_id = ?", sessionID). - Select("COALESCE(MAX(seq), 0)"). - Scan(&maxSeq).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"}) - return - } - - hasMore := len(messages) >= limit && (len(messages) == 0 || messages[len(messages)-1].Seq < maxSeq) - c.JSON(http.StatusOK, gin.H{ - "code": 0, - "data": gin.H{ - "messages": messages, - "after_seq": afterSeq, - "max_seq": maxSeq, - "has_more": hasMore, - "session_status": session.Status, - "satisfaction_score": session.SatisfactionScore, - }, - }) + c.JSON(http.StatusOK, gin.H{"code": 0, "data": data}) } diff --git a/server/internal/handler/assignment.go b/server/internal/service/assignment.go similarity index 85% rename from server/internal/handler/assignment.go rename to server/internal/service/assignment.go index 74daef2..d8c6394 100644 --- a/server/internal/handler/assignment.go +++ b/server/internal/service/assignment.go @@ -1,4 +1,4 @@ -package handler +package service import ( "fmt" @@ -17,17 +17,16 @@ func assignmentLock(tenantID uint) *sync.Mutex { } const ( - assignStrategyLeastLoad = "least_load" - assignStrategyRoundRobin = "round_robin" - offlineLeavePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" + AssignStrategyLeastLoad = "least_load" + AssignStrategyRoundRobin = "round_robin" ) -func normalizeAssignStrategy(raw string) string { +func NormalizeAssignStrategy(raw string) string { switch strings.TrimSpace(raw) { - case assignStrategyRoundRobin: - return assignStrategyRoundRobin + case AssignStrategyRoundRobin: + return AssignStrategyRoundRobin default: - return assignStrategyLeastLoad + return AssignStrategyLeastLoad } } @@ -122,15 +121,15 @@ func pickAgent(tenantID uint, strategy string, maxActive int, rrLast *uint) (*mo if len(candidates) == 0 { return nil, nil } - strategy = normalizeAssignStrategy(strategy) - if strategy == assignStrategyRoundRobin { + strategy = NormalizeAssignStrategy(strategy) + if strategy == AssignStrategyRoundRobin { return pickRoundRobinAgent(candidates, rrLast), nil } return pickLeastLoadedAgent(candidates, counts), nil } -// countOnlineAgents 统计租户当前可接待的在线客服数。 -func countOnlineAgents(tenantID uint) (int64, error) { +// CountOnlineAgents 统计租户当前可接待的在线客服数。 +func CountOnlineAgents(tenantID uint) (int64, error) { var count int64 err := model.DB.Model(&model.User{}). Where("tenant_id = ? AND role = ? AND status = ?", tenantID, "agent", "online"). @@ -138,8 +137,8 @@ func countOnlineAgents(tenantID uint) (int64, error) { return count, err } -// tryAutoAssign 按租户分配策略自动分配 waiting 会话;无可分配客服时返回 nil。 -func tryAutoAssign(session *model.Session) (*model.User, error) { +// TryAutoAssign 按租户分配策略自动分配 waiting 会话;无可分配客服时返回 nil。 +func TryAutoAssign(session *model.Session) (*model.User, error) { if session == nil || session.ID == 0 { return nil, nil } @@ -157,12 +156,12 @@ func tryAutoAssign(session *model.Session) (*model.User, error) { return nil, nil } - setting, err := getOrCreateTenantSettings(session.TenantID) + setting, err := GetOrCreateTenantSettings(session.TenantID) if err != nil { // 设置失败时回退默认 least_load、不限制并发 - setting = &model.TenantSetting{AssignStrategy: assignStrategyLeastLoad} + setting = &model.TenantSetting{AssignStrategy: AssignStrategyLeastLoad} } - strategy := normalizeAssignStrategy(setting.AssignStrategy) + strategy := NormalizeAssignStrategy(setting.AssignStrategy) maxActive := setting.MaxActivePerAgent if maxActive < 0 { maxActive = 0 @@ -195,7 +194,7 @@ func tryAutoAssign(session *model.Session) (*model.User, error) { session.Status = "active" // 推进轮询游标(仅 round_robin) - if strategy == assignStrategyRoundRobin && setting.ID > 0 { + if strategy == AssignStrategyRoundRobin && setting.ID > 0 { aid := agent.ID _ = model.DB.Model(&model.TenantSetting{}). Where("id = ?", setting.ID). @@ -204,7 +203,7 @@ func tryAutoAssign(session *model.Session) (*model.User, error) { } strategyLabel := "负载最低" - if strategy == assignStrategyRoundRobin { + if strategy == AssignStrategyRoundRobin { strategyLabel = "轮询" } detail := fmt.Sprintf("系统自动分配给 %s(%s)", agent.Nickname, strategyLabel) diff --git a/server/internal/service/blacklist.go b/server/internal/service/blacklist.go new file mode 100644 index 0000000..b5951e8 --- /dev/null +++ b/server/internal/service/blacklist.go @@ -0,0 +1,58 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" + "unicode/utf8" + + "kefu-cloud/server/internal/model" +) + +// NormalizeDeviceKey 规范化设备指纹:优先客户端 device_id;否则用 UA 摘要哈希兜底。 +func NormalizeDeviceKey(deviceID, userAgent string) string { + id := strings.TrimSpace(deviceID) + if id != "" { + if utf8.RuneCountInString(id) > 64 { + id = string([]rune(id)[:64]) + } + return id + } + ua := strings.TrimSpace(userAgent) + if ua == "" { + return "" + } + sum := sha256.Sum256([]byte(strings.ToLower(ua))) + return hex.EncodeToString(sum[:])[:32] +} + +// IsBlacklisted 检查租户下 IP/设备是否在有效黑名单中。 +func IsBlacklisted(tenantID uint, kind, value string) (bool, *model.BlacklistEntry) { + value = strings.TrimSpace(value) + if value == "" || (kind != "ip" && kind != "device") { + return false, nil + } + now := time.Now() + var entry model.BlacklistEntry + err := model.DB. + Where("tenant_id = ? AND kind = ? AND value = ?", tenantID, kind, value). + Where("expires_at IS NULL OR expires_at > ?", now). + Order("id desc"). + First(&entry).Error + if err != nil { + return false, nil + } + return true, &entry +} + +// CheckVisitorBlacklist 同时检查 IP 与设备。 +func CheckVisitorBlacklist(tenantID uint, ip, deviceKey string) (blocked bool, kind string, reason string) { + if ok, e := IsBlacklisted(tenantID, "ip", ip); ok { + return true, "ip", e.Reason + } + if ok, e := IsBlacklisted(tenantID, "device", deviceKey); ok { + return true, "device", e.Reason + } + return false, "", "" +} diff --git a/server/internal/handler/contact_extract.go b/server/internal/service/contact_extract.go similarity index 80% rename from server/internal/handler/contact_extract.go rename to server/internal/service/contact_extract.go index e5bbbfd..3d3f579 100644 --- a/server/internal/handler/contact_extract.go +++ b/server/internal/service/contact_extract.go @@ -1,4 +1,4 @@ -package handler +package service import ( "regexp" @@ -10,22 +10,22 @@ import ( ) var ( - reMobileCN = regexp.MustCompile(`(?i)(?:手机|电话|联系|号|tel|phone|mobile)?[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})`) + reMobileCN = regexp.MustCompile(`(?i)(?:手机|电话|联系|号|tel|phone|mobile)?[::\s]*((?:\+?86[-\s]?)?1[3-9]\d{9})`) reMobileStrict = regexp.MustCompile(`(?:^|[^\d])(1[3-9]\d{9})(?:[^\d]|$)`) - reEmail = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) + reEmail = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) // 允许「微信是 xxx / 微信:xxx / 微信号 xxx」等口语写法 reWechat = regexp.MustCompile(`(?i)(?:微信|微信号|vx|v信|wx)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([a-zA-Z][-_a-zA-Z0-9]{5,19})`) reWechatID = regexp.MustCompile(`(?i)\b(wxid_[a-zA-Z0-9]{5,20})\b`) reQQ = regexp.MustCompile(`(?i)(?:QQ|扣扣)[号]?(?:是|为|:|:|[\s ]+)[\s ]*([1-9]\d{4,11})`) ) -type extractedContact struct { +type ExtractedContact struct { Kind string Value string } -// extractContactsFromText 从文本中提取联系方式(手机/微信/邮箱/QQ)。 -func extractContactsFromText(text string) []extractedContact { +// ExtractContactsFromText 从文本中提取联系方式(手机/微信/邮箱/QQ)。 +func ExtractContactsFromText(text string) []ExtractedContact { text = strings.TrimSpace(text) if text == "" { return nil @@ -36,7 +36,7 @@ func extractContactsFromText(text string) []extractedContact { } seen := map[string]bool{} - var out []extractedContact + var out []ExtractedContact add := func(kind, val string) { val = strings.TrimSpace(val) if val == "" { @@ -57,7 +57,7 @@ func extractContactsFromText(text string) []extractedContact { return } seen[key] = true - out = append(out, extractedContact{Kind: kind, Value: val}) + out = append(out, ExtractedContact{Kind: kind, Value: val}) } for _, m := range reMobileCN.FindAllStringSubmatch(text, -1) { @@ -91,10 +91,10 @@ func extractContactsFromText(text string) []extractedContact { return out } -// mergeCustomerContacts 将提取结果写入客户联系方式表(已存在则跳过,不覆盖)。 +// MergeCustomerContacts 将提取结果写入客户联系方式表(已存在则跳过,不覆盖)。 // 同时:若 customers.phone/email 为空,用第一条补全(兼容旧列表搜索)。 // 返回新插入的条数。 -func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContact, source string) (int, []model.CustomerContact) { +func MergeCustomerContacts(tenantID, customerID uint, contacts []ExtractedContact, source string) (int, []model.CustomerContact) { if customerID == 0 || len(contacts) == 0 { return 0, nil } @@ -152,19 +152,20 @@ func mergeCustomerContacts(tenantID, customerID uint, contacts []extractedContac return added, inserted } -func listCustomerContacts(customerID uint) []model.CustomerContact { +// ListCustomerContacts 客户全部联系方式(按 id 升序)。 +func ListCustomerContacts(customerID uint) []model.CustomerContact { var list []model.CustomerContact model.DB.Where("customer_id = ?", customerID).Order("id asc").Find(&list) return list } -// onVisitorDraftContacts 草稿文本提取联系方式并通知坐席刷新客户资料。 -func onVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) { - contacts := extractContactsFromText(text) +// OnVisitorDraftContacts 草稿文本提取联系方式并通知坐席刷新客户资料。 +func OnVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) { + contacts := ExtractContactsFromText(text) if len(contacts) == 0 { return } - n, inserted := mergeCustomerContacts(tenantID, customerID, contacts, "draft") + n, inserted := MergeCustomerContacts(tenantID, customerID, contacts, "draft") if n == 0 { return } @@ -173,7 +174,7 @@ func onVisitorDraftContacts(tenantID, customerID, sessionID uint, text string) { if err := model.DB.First(&customer, customerID).Error; err != nil { return } - all := listCustomerContacts(customerID) + all := ListCustomerContacts(customerID) payload, err := ws.NewEvent("customer_updated", sessionID, map[string]interface{}{ "customer": customer, "contacts": all, diff --git a/server/internal/service/errors.go b/server/internal/service/errors.go new file mode 100644 index 0000000..6edfcb8 --- /dev/null +++ b/server/internal/service/errors.go @@ -0,0 +1,21 @@ +package service + +// Error 带语义状态码的业务错误,由 handler 层映射为 HTTP 响应。 +type Error struct { + Status int + Message string +} + +func (e *Error) Error() string { return e.Message } + +// E 构造业务错误。 +func E(status int, message string) *Error { + return &Error{Status: status, Message: message} +} + +// BadRequest / Forbidden / NotFound / Conflict / Internal 便捷构造。 +func BadRequest(message string) *Error { return &Error{Status: 400, Message: message} } +func Forbidden(message string) *Error { return &Error{Status: 403, Message: message} } +func NotFound(message string) *Error { return &Error{Status: 404, Message: message} } +func Conflict(message string) *Error { return &Error{Status: 409, Message: message} } +func Internal(message string) *Error { return &Error{Status: 500, Message: message} } diff --git a/server/internal/handler/message_validation.go b/server/internal/service/message_validation.go similarity index 85% rename from server/internal/handler/message_validation.go rename to server/internal/service/message_validation.go index b8ceb86..5206fcb 100644 --- a/server/internal/handler/message_validation.go +++ b/server/internal/service/message_validation.go @@ -1,4 +1,4 @@ -package handler +package service import ( "errors" @@ -15,11 +15,13 @@ const ( // imagePublicBase 由 SetupRoutes 注入,用于校验图片消息 URL 归属 var imagePublicBase string +// SetImagePublicBase 设置允许的图片存储公开前缀。 func SetImagePublicBase(base string) { imagePublicBase = storage.NormalizePublicBase(base) } -func validateMessageContent(messageType, content string) (string, error) { +// ValidateMessageContent 校验消息内容;文本去空白,图片校验存储白名单。 +func ValidateMessageContent(messageType, content string) (string, error) { switch messageType { case "text": content = strings.TrimSpace(content) diff --git a/server/internal/handler/page_track.go b/server/internal/service/page_track.go similarity index 89% rename from server/internal/handler/page_track.go rename to server/internal/service/page_track.go index a25dd40..60d115c 100644 --- a/server/internal/handler/page_track.go +++ b/server/internal/service/page_track.go @@ -1,4 +1,4 @@ -package handler +package service import ( "net/url" @@ -10,7 +10,7 @@ const ( maxPageURLLen = 1000 maxPageTitleLen = 200 // 单会话最多保留轨迹条数(超出删最旧) - maxPageViewsPerSession = 50 + MaxPageViewsPerSession = 50 ) // 常见敏感 query 参数,上报 URL 时剥离 @@ -35,8 +35,8 @@ func truncateRunes(s string, max int) string { return string(r[:max]) } -// sanitizePageURL 规范化并脱敏 URL,非法则返回空。 -func sanitizePageURL(raw string) string { +// SanitizePageURL 规范化并脱敏 URL,非法则返回空。 +func SanitizePageURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" @@ -77,6 +77,7 @@ func sanitizePageURL(raw string) string { return truncateRunes(out, maxPageURLLen) } -func sanitizePageTitle(raw string) string { +// SanitizePageTitle 截断页面标题。 +func SanitizePageTitle(raw string) string { return truncateRunes(raw, maxPageTitleLen) } diff --git a/server/internal/service/session.go b/server/internal/service/session.go new file mode 100644 index 0000000..cc4c8d9 --- /dev/null +++ b/server/internal/service/session.go @@ -0,0 +1,651 @@ +package service + +import ( + "sort" + "strings" + "time" + "unicode/utf8" + + "gorm.io/gorm" + "kefu-cloud/server/internal/model" +) + +// Actor 请求发起者(由 handler 从鉴权中间件结果构造),service 层据此做数据范围判断。 +type Actor struct { + UserID uint + SessionAll bool // 对 session 模块具有全量数据范围(主管/管理员) + HistoryAll bool // 对 chat_history 模块具有全量数据范围 +} + +// SessionListItem 会话列表项(含批量补全的关联信息)。 +type SessionListItem struct { + model.Session + UnreadCount int `json:"unread_count"` + LastMessage string `json:"last_message"` + LastMessageAt *time.Time `json:"last_message_at,omitempty"` + MessageCount int `json:"message_count"` + CustomerName string `json:"customer_name"` + AgentName string `json:"agent_name"` + ChannelName string `json:"channel_name"` + ChannelType string `json:"channel_type"` +} + +// AgentItem 可转接/筛选坐席项。 +type AgentItem struct { + ID uint `json:"id"` + Nickname string `json:"nickname"` + Status string `json:"status"` // online | busy | offline | disabled + Role string `json:"role,omitempty"` +} + +// SessionFilter 会话列表筛选。 +type SessionFilter struct { + Status string + Priority string + AgentID string + ChannelID string + Search string + From string + To string + Page int + PageSize int +} + +// LoadTenantSession 加载本租户会话;跨租户访问返回 403。 +func LoadTenantSession(id string, tenantID uint) (*model.Session, error) { + var session model.Session + if err := model.DB.First(&session, id).Error; err != nil { + return nil, NotFound("会话不存在") + } + if session.TenantID != tenantID { + return nil, Forbidden("无权访问其他租户会话") + } + return &session, nil +} + +// CanReadSession 按会话状态区分数据范围(进行中走 session,已结束走 chat_history)。 +func CanReadSession(actor Actor, session *model.Session) bool { + all := actor.SessionAll + if session.Status == "ended" || session.Status == "archived" { + all = actor.HistoryAll + } + if all { + return true + } + return session.Status == "waiting" || (session.AgentID != nil && *session.AgentID == actor.UserID) +} + +// CanOperateSession 是否可操作(转接/结束/备注/发消息)该会话。 +func CanOperateSession(actor Actor, session *model.Session) bool { + if actor.SessionAll { + return true + } + return session.AgentID != nil && *session.AgentID == actor.UserID +} + +// loadAssignableAgent 校验目标坐席属于本租户、未停用,且具备接待能力。 +func loadAssignableAgent(tenantID, agentID uint) error { + var agent model.User + if err := model.DB.First(&agent, agentID).Error; err != nil { + return err + } + 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 +} + +// assignableRoleCodes 可被分配/转接的角色(内置 agent + 拥有 session.reply 的自定义角色)。 +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{}). + Where("session_id = ? AND sender_type = ? AND seq > ?", session.ID, "visitor", session.LastReadSeq). + Count(&count) + return int(count) +} + +// ListSessions 会话列表:筛选、分页并批量补全客户/坐席/渠道/消息数。 +func ListSessions(actor Actor, tenantID uint, f SessionFilter) ([]SessionListItem, int64, error) { + var sessions []model.Session + var total int64 + + query := model.DB.Model(&model.Session{}).Where("sessions.tenant_id = ?", tenantID) + if !actor.SessionAll || !actor.HistoryAll { + query = query.Where("sessions.agent_id = ? OR sessions.status = ?", actor.UserID, "waiting") + } + if f.Status != "" { + query = query.Where("sessions.status = ?", f.Status) + } + if f.Priority != "" { + query = query.Where("sessions.priority = ?", f.Priority) + } + if f.AgentID != "" { + query = query.Where("sessions.agent_id = ?", f.AgentID) + } + if f.ChannelID != "" { + query = query.Where("sessions.channel_id = ?", f.ChannelID) + } + if f.From != "" { + if t, err := time.ParseInLocation("2006-01-02", f.From, time.Local); err == nil { + query = query.Where("sessions.created_at >= ?", t) + } + } + if f.To != "" { + if t, err := time.ParseInLocation("2006-01-02", f.To, time.Local); err == nil { + query = query.Where("sessions.created_at < ?", t.Add(24*time.Hour)) + } + } + if f.Search != "" { + like := "%" + f.Search + "%" + query = query.Joins("LEFT JOIN customers ON customers.id = sessions.customer_id"). + Where("customers.name LIKE ? OR CAST(sessions.id AS TEXT) LIKE ? OR COALESCE(sessions.end_reason, '') LIKE ?", like, like, like) + } + + query.Count(&total) + if err := query.Order("sessions.created_at desc").Offset((f.Page - 1) * f.PageSize).Limit(f.PageSize).Find(&sessions).Error; err != nil { + return nil, 0, Internal("查询会话失败") + } + + // 批量补全客户 / 客服 / 渠道 / 消息数 + customerIDs := make([]uint, 0) + agentIDs := make([]uint, 0) + channelIDs := make([]uint, 0) + sessionIDs := make([]uint, 0, len(sessions)) + for _, s := range sessions { + sessionIDs = append(sessionIDs, s.ID) + customerIDs = append(customerIDs, s.CustomerID) + channelIDs = append(channelIDs, s.ChannelID) + if s.AgentID != nil { + agentIDs = append(agentIDs, *s.AgentID) + } + } + customerMap := map[uint]model.Customer{} + if len(customerIDs) > 0 { + var customers []model.Customer + model.DB.Where("id IN ?", UniqueUint(customerIDs)).Find(&customers) + for _, cu := range customers { + customerMap[cu.ID] = cu + } + } + agentMap := map[uint]model.User{} + if len(agentIDs) > 0 { + var users []model.User + model.DB.Where("id IN ?", UniqueUint(agentIDs)).Find(&users) + for _, u := range users { + agentMap[u.ID] = u + } + } + channelMap := map[uint]model.Channel{} + if len(channelIDs) > 0 { + var channels []model.Channel + model.DB.Where("id IN ?", UniqueUint(channelIDs)).Find(&channels) + for _, ch := range channels { + channelMap[ch.ID] = ch + } + } + msgCountMap := map[uint]int{} + if len(sessionIDs) > 0 { + type row struct { + SessionID uint + Cnt int + } + var rows []row + model.DB.Model(&model.Message{}). + Select("session_id, COUNT(*) as cnt"). + Where("session_id IN ?", sessionIDs). + Group("session_id"). + Scan(&rows) + for _, r := range rows { + msgCountMap[r.SessionID] = r.Cnt + } + } + + items := make([]SessionListItem, 0, len(sessions)) + for _, session := range sessions { + item := SessionListItem{Session: session, MessageCount: msgCountMap[session.ID]} + if !actor.SessionAll && session.AgentID != nil && *session.AgentID == actor.UserID { + item.UnreadCount = unreadCount(session) + } + if cu, ok := customerMap[session.CustomerID]; ok { + item.CustomerName = cu.Name + } + if session.AgentID != nil { + if u, ok := agentMap[*session.AgentID]; ok { + item.AgentName = u.Nickname + } + } + if ch, ok := channelMap[session.ChannelID]; ok { + item.ChannelName = ch.Name + item.ChannelType = ch.Type + } + var lastMsg model.Message + if err := model.DB.Where("session_id = ?", session.ID).Order("seq desc").First(&lastMsg).Error; err == nil { + if lastMsg.Type == "image" { + item.LastMessage = "[图片]" + } else { + item.LastMessage = lastMsg.Content + if utf8.RuneCountInString(item.LastMessage) > 40 { + runes := []rune(item.LastMessage) + item.LastMessage = string(runes[:40]) + "…" + } + } + t := lastMsg.SentAt + item.LastMessageAt = &t + } + items = append(items, item) + } + + return items, total, nil +} + +// SessionDetailResult 会话详情聚合结果。 +type SessionDetailResult struct { + Messages []model.Message + Events []model.SessionEvent + PageViews []model.VisitorPageView + PendingCount int64 + MaxSeq int +} + +// SessionDetail 会话详情:消息、事件、浏览轨迹与同客户排队数。 +func SessionDetail(session *model.Session) (*SessionDetailResult, error) { + var messages []model.Message + if err := model.DB.Where("session_id = ?", session.ID).Order("seq asc").Find(&messages).Error; err != nil { + return nil, Internal("查询消息失败") + } + var events []model.SessionEvent + model.DB.Where("session_id = ?", session.ID).Order("created_at asc").Find(&events) + var pageViews []model.VisitorPageView + // 最新在前,坐席侧无需滚到底 + model.DB.Where("session_id = ?", session.ID).Order("entered_at desc").Limit(MaxPageViewsPerSession).Find(&pageViews) + var pendingCount int64 + model.DB.Model(&model.Session{}).Where("customer_id = ? AND tenant_id = ? AND status = ?", session.CustomerID, session.TenantID, "waiting").Count(&pendingCount) + + maxSeq := 0 + for _, m := range messages { + if m.Seq > maxSeq { + maxSeq = m.Seq + } + } + return &SessionDetailResult{ + Messages: messages, + Events: events, + PageViews: pageViews, + PendingCount: pendingCount, + MaxSeq: maxSeq, + }, nil +} + +// MessagePage 按 seq 增量拉取的消息页。 +type MessagePage struct { + Messages []model.Message + AfterSeq int + MaxSeq int + HasMore bool +} + +// SessionMessagePage 按 seq 增量拉取消息(重连 catch-up)。 +func SessionMessagePage(sessionID uint, afterSeq, limit int) (*MessagePage, error) { + query := model.DB.Where("session_id = ?", sessionID) + if afterSeq > 0 { + query = query.Where("seq > ?", afterSeq) + } + + var messages []model.Message + if err := query.Order("seq asc").Limit(limit).Find(&messages).Error; err != nil { + return nil, Internal("查询消息失败") + } + + var maxSeq int + if err := model.DB.Model(&model.Message{}). + Where("session_id = ?", sessionID). + Select("COALESCE(MAX(seq), 0)"). + Scan(&maxSeq).Error; err != nil { + return nil, Internal("查询消息失败") + } + + return &MessagePage{ + Messages: messages, + AfterSeq: afterSeq, + MaxSeq: maxSeq, + HasMore: len(messages) >= limit && (len(messages) == 0 || messages[len(messages)-1].Seq < maxSeq), + }, nil +} + +// MarkSessionRead 坐席标记已读;仅会话归属坐席生效,其余原样返回当前进度。 +func MarkSessionRead(session *model.Session, actor Actor) (int, error) { + if session.AgentID == nil || actor.SessionAll || *session.AgentID != actor.UserID { + return session.LastReadSeq, nil + } + var maxSeq int + if err := model.DB.Model(&model.Message{}).Where("session_id = ?", session.ID).Select("COALESCE(MAX(seq), 0)").Scan(&maxSeq).Error; err != nil { + return 0, Internal("标记已读失败") + } + if err := model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", maxSeq).Error; err != nil { + return 0, Internal("标记已读失败") + } + return maxSeq, nil +} + +// AddSessionNote 添加内部备注(1-500 字)。 +func AddSessionNote(session *model.Session, operatorID uint, content string) (model.SessionEvent, error) { + content = strings.TrimSpace(content) + if content == "" || utf8.RuneCountInString(content) > 500 { + return model.SessionEvent{}, BadRequest("内部备注需为 1 至 500 字") + } + event := model.SessionEvent{SessionID: session.ID, OperatorID: operatorID, Action: "note", Detail: content} + if err := model.DB.Create(&event).Error; err != nil { + return model.SessionEvent{}, Internal("保存内部备注失败") + } + return event, nil +} + +// ListAvailableAgents 可转接/筛选坐席列表;includeOffline 用于对话记录筛选。 +func ListAvailableAgents(tenantID uint, includeOffline bool) ([]AgentItem, error) { + q := model.DB.Where("tenant_id = ? AND role IN ?", tenantID, assignableRoleCodes(tenantID)) + if !includeOffline { + q = q.Where("status IN ?", []string{"online", "busy"}) + } + var users []model.User + if err := q.Order("nickname asc").Find(&users).Error; err != nil { + return nil, Internal("查询客服失败") + } + // 在线优先,其次忙碌,再按昵称(Order 已按昵称,稳定排序保持次序) + statusRank := map[string]int{"online": 0, "busy": 1, "offline": 2, "disabled": 3} + sort.SliceStable(users, func(i, j int) bool { + return statusRank[users[i].Status] < statusRank[users[j].Status] + }) + items := make([]AgentItem, 0, len(users)) + for _, user := range users { + items = append(items, AgentItem{ + ID: user.ID, Nickname: user.Nickname, Status: user.Status, Role: user.Role, + }) + } + return items, nil +} + +// CreateSession 主管/管理员代客建会话。 +func CreateSession(tenantID, channelID, customerID uint, priority string) (model.Session, error) { + var channel model.Channel + if err := model.DB.Where("id = ? AND tenant_id = ? AND status = ?", channelID, tenantID, "enabled").First(&channel).Error; err != nil { + return model.Session{}, BadRequest("渠道不存在或未启用") + } + var customer model.Customer + if err := model.DB.Where("id = ? AND tenant_id = ?", customerID, tenantID).First(&customer).Error; err != nil { + return model.Session{}, BadRequest("客户不存在") + } + + session := model.Session{ + TenantID: tenantID, + ChannelID: channelID, + CustomerID: customerID, + Priority: priority, + Status: "waiting", + } + + if session.Priority == "" { + session.Priority = "normal" + } + + if err := model.DB.Create(&session).Error; err != nil { + return model.Session{}, Internal("创建会话失败") + } + model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, tenantID). + Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) + + return session, nil +} + +// AssignSession 分配/领取会话(仅 waiting)。 +// 无转接权限时强制分配给本人;有权限的非管理员只能领取给自己。 +func AssignSession(session *model.Session, actor Actor, canTransfer bool, agentID uint) (string, error) { + if session.Status != "waiting" { + return "", Conflict("会话已被分配") + } + if !canTransfer { + agentID = actor.UserID + } else if !actor.SessionAll && agentID != actor.UserID { + return "", Forbidden("无权分配会话") + } + if agentID == 0 { + return "", BadRequest("请选择目标客服") + } + if err := loadAssignableAgent(session.TenantID, agentID); err != nil { + return "", BadRequest("目标客服不存在或不可用") + } + + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "waiting"). + Updates(map[string]interface{}{"agent_id": agentID, "status": "active", "last_read_seq": 0}) + + if result.Error != nil { + return "", Internal("分配失败") + } + if result.RowsAffected == 0 { + return "", Conflict("会话已被分配") + } + + operatorID := actor.UserID + operatorName := UserDisplayName(operatorID) + targetName := UserDisplayName(agentID) + detail := "会话分配给 " + targetName + if operatorID == agentID { + detail = operatorName + " 领取了会话" + } else { + detail = operatorName + " 将会话分配给 " + targetName + } + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "assign", + Detail: detail, + }) + session.AgentID = &agentID + session.Status = "active" + BroadcastSessionUpdate(session) + return detail, nil +} + +// TransferSession 转接进行中的会话。 +func TransferSession(session *model.Session, actor Actor, agentID uint) (string, error) { + if err := loadAssignableAgent(session.TenantID, agentID); err != nil { + return "", BadRequest("目标客服不存在或不可用") + } + if session.AgentID != nil && *session.AgentID == agentID { + return "", BadRequest("不能转接给当前接待坐席") + } + + operatorID := actor.UserID + operatorName := UserDisplayName(operatorID) + targetName := UserDisplayName(agentID) + fromName := "" + if session.AgentID != nil { + fromName = UserDisplayName(*session.AgentID) + } + + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). + Updates(map[string]interface{}{"agent_id": agentID, "last_read_seq": 0}) + if result.Error != nil { + return "", Internal("转接失败") + } + if result.RowsAffected == 0 { + return "", Conflict("会话不可转接") + } + + // 详情写明「谁 → 谁」,便于接手坐席在备注/时间线中一眼看清 + detail := operatorName + " 转接给 " + targetName + if fromName != "" && fromName != operatorName { + detail = operatorName + " 将会话由 " + fromName + " 转接给 " + targetName + } + + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "transfer", + Detail: detail, + }) + session.AgentID = &agentID + BroadcastSessionUpdate(session) + + return detail, nil +} + +var endReasons = map[string]bool{ + "resolved": true, "no_response": true, "visitor_left": true, "transferred": true, "other": true, +} + +// EndSession 结束会话(记录原因)。 +func EndSession(session *model.Session, operatorID uint, reason string) error { + if reason == "" { + return BadRequest("请填写结束原因") + } + if !endReasons[reason] { + return BadRequest("结束原因无效") + } + now := time.Now() + + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status <> ?", session.ID, session.TenantID, "ended"). + Updates(map[string]interface{}{"status": "ended", "end_reason": reason, "ended_at": now}) + + if result.RowsAffected == 0 { + return NotFound("会话不存在") + } + + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "end", + Detail: "结束会话: " + reason, + }) + session.Status = "ended" + session.EndedAt = &now + BroadcastSessionUpdate(session) + + return nil +} + +// UpdateSessionPriority 调整会话优先级(urgent / normal)。 +func UpdateSessionPriority(session *model.Session, actor Actor, priority string) error { + if priority != "urgent" && priority != "normal" { + return BadRequest("优先级无效") + } + + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ?", session.ID, session.TenantID). + Update("priority", priority) + if result.Error != nil || result.RowsAffected == 0 { + return Internal("更新失败") + } + session.Priority = priority + BroadcastSessionUpdate(session) + + return nil +} + +// ArchiveSession 将已结束会话归档。 +func ArchiveSession(session *model.Session, operatorID uint) (*model.Session, error) { + if session.Status != "ended" { + return nil, BadRequest("仅已结束的会话可归档") + } + result := model.DB.Model(&model.Session{}). + Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "ended"). + Update("status", "archived") + if result.Error != nil || result.RowsAffected == 0 { + return nil, Internal("归档失败") + } + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: operatorID, + Action: "archive", + Detail: "会话已归档", + }) + session.Status = "archived" + BroadcastSessionUpdate(session) + return session, nil +} + +// BatchArchiveSessions 批量归档已结束会话,返回归档条数。 +func BatchArchiveSessions(tenantID, operatorID uint, ids []uint) (int64, error) { + if len(ids) == 0 { + return 0, BadRequest("请选择要归档的会话") + } + if len(ids) > 100 { + return 0, BadRequest("单次最多归档 100 条") + } + result := model.DB.Model(&model.Session{}). + Where("tenant_id = ? AND status = ? AND id IN ?", tenantID, "ended", ids). + Update("status", "archived") + if result.Error != nil { + return 0, Internal("批量归档失败") + } + for _, id := range ids { + model.DB.Create(&model.SessionEvent{ + SessionID: id, + OperatorID: operatorID, + Action: "archive", + Detail: "会话已归档", + }) + } + return result.RowsAffected, nil +} + +// AgentSendMessage 坐席向会话发送消息并广播。 +func AgentSendMessage(session *model.Session, actor Actor, msgType, content string) (*model.Message, error) { + validated, err := ValidateMessageContent(msgType, content) + if err != nil { + return nil, BadRequest(err.Error()) + } + + if !CanOperateSession(actor, session) { + return nil, Forbidden("无权向该会话发送消息") + } + if session.Status == "ended" || session.Status == "archived" { + return nil, Conflict("会话已结束") + } + + senderID := actor.UserID + msg := model.Message{ + SessionID: session.ID, + SenderType: "agent", + SenderID: &senderID, + Content: validated, + Type: msgType, + SentAt: time.Now(), + } + + if err := model.CreateMessage(&msg); err != nil { + return nil, Internal("发送失败") + } + // 一线坐席发送后同步自己的已读进度;管理员/主管非会话归属,不推进 + if !actor.SessionAll { + model.DB.Model(&model.Session{}).Where("id = ?", session.ID).Update("last_read_seq", msg.Seq) + session.LastReadSeq = msg.Seq + } + BroadcastSessionMessage(session, msg) + + return &msg, nil +} diff --git a/server/internal/service/tenant_settings.go b/server/internal/service/tenant_settings.go new file mode 100644 index 0000000..2b3da72 --- /dev/null +++ b/server/internal/service/tenant_settings.go @@ -0,0 +1,487 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" + "unicode/utf8" + + "kefu-cloud/server/internal/model" +) + +const ( + DefaultWelcomeMessage = "您好!欢迎咨询,请问有什么可以帮您?" + DefaultOfflinePrompt = "当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。" + DefaultWorktimePrompt = "当前为非工作时间,我们会在工作时段尽快回复您。" + DefaultAgentNickname = "在线客服" + defaultTimezone = "Asia/Shanghai" + + MaxWelcomeSegments = 10 + maxWelcomeTextLen = 1000 // 纯文字上限(HTML 标签不计) + maxWelcomeHTMLLen = 8000 // 富文本原始 HTML 上限 +) + +// stripHTMLTags 粗暴去标签,用于字数与摘要(欢迎语 HTML)。 +func stripHTMLTags(s string) string { + var b strings.Builder + inTag := false + for _, r := range s { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + case !inTag: + b.WriteRune(r) + } + } + // 简单实体 + out := b.String() + out = strings.ReplaceAll(out, " ", " ") + out = strings.ReplaceAll(out, "&", "&") + out = strings.ReplaceAll(out, "<", "<") + out = strings.ReplaceAll(out, ">", ">") + return strings.TrimSpace(out) +} + +// WelcomeSegment 单段欢迎语:文本或图片(content 为正文或对象存储 URL)。 +type WelcomeSegment struct { + Type string `json:"type"` // text | image + Content string `json:"content"` +} + +var weekdayKeys = []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"} + +func defaultWelcomeSegments() []WelcomeSegment { + return []WelcomeSegment{{Type: "text", Content: DefaultWelcomeMessage}} +} + +func defaultWelcomeMessagesJSON() string { + b, _ := json.Marshal(defaultWelcomeSegments()) + return string(b) +} + +// FirstWelcomeText 取第一段可用文本欢迎语(剥离 HTML),用于旧字段兼容展示。 +func FirstWelcomeText(segs []WelcomeSegment) string { + for _, s := range segs { + if s.Type != "text" { + continue + } + plain := stripHTMLTags(s.Content) + if plain == "" { + plain = strings.TrimSpace(s.Content) + } + if plain == "" { + continue + } + // welcome_message 列 size:500 + if utf8.RuneCountInString(plain) > 500 { + return string([]rune(plain)[:500]) + } + return plain + } + return DefaultWelcomeMessage +} + +// ResolveWelcomeSegments 解析多段欢迎语;兼容仅有旧 welcome_message 的数据。 +func ResolveWelcomeSegments(setting *model.TenantSetting) []WelcomeSegment { + if setting == nil { + return defaultWelcomeSegments() + } + raw := strings.TrimSpace(setting.WelcomeMessagesJSON) + if raw != "" { + var segs []WelcomeSegment + if err := json.Unmarshal([]byte(raw), &segs); err == nil { + out := make([]WelcomeSegment, 0, len(segs)) + for _, s := range segs { + t := strings.TrimSpace(s.Type) + c := strings.TrimSpace(s.Content) + if (t == "text" || t == "image") && c != "" { + out = append(out, WelcomeSegment{Type: t, Content: c}) + } + } + if len(out) > 0 { + return out + } + } + } + if msg := strings.TrimSpace(setting.WelcomeMessage); msg != "" { + return []WelcomeSegment{{Type: "text", Content: msg}} + } + return defaultWelcomeSegments() +} + +// NormalizeWelcomeSegments 校验并规范化欢迎语段落;返回规范化列表与兼容用首段文本。 +func NormalizeWelcomeSegments(input []WelcomeSegment) ([]WelcomeSegment, string, error) { + if len(input) == 0 { + return nil, "", fmt.Errorf("至少保留一段欢迎语") + } + if len(input) > MaxWelcomeSegments { + return nil, "", fmt.Errorf("欢迎语最多 %d 段", MaxWelcomeSegments) + } + out := make([]WelcomeSegment, 0, len(input)) + for i, seg := range input { + t := strings.ToLower(strings.TrimSpace(seg.Type)) + c := strings.TrimSpace(seg.Content) + switch t { + case "text": + // 允许 HTML 富文本;禁止明显脚本 + lower := strings.ToLower(c) + if strings.Contains(lower, " maxWelcomeTextLen { + return nil, "", fmt.Errorf("第 %d 段文字不能超过 %d 字", i+1, maxWelcomeTextLen) + } + if utf8.RuneCountInString(c) > maxWelcomeHTMLLen { + return nil, "", fmt.Errorf("第 %d 段内容过长", i+1) + } + out = append(out, WelcomeSegment{Type: "text", Content: c}) + case "image": + validated, err := ValidateMessageContent("image", c) + if err != nil { + return nil, "", fmt.Errorf("第 %d 段图片:%s", i+1, err.Error()) + } + out = append(out, WelcomeSegment{Type: "image", Content: validated}) + default: + return nil, "", fmt.Errorf("第 %d 段类型无效,请使用 text 或 image", i+1) + } + } + return out, FirstWelcomeText(out), nil +} + +func defaultWorkHoursJSON() string { + // 默认全周全天可接待,租户可在设置中收紧为工作日时段 + hours := map[string]string{ + "monday": "全天", "tuesday": "全天", "wednesday": "全天", + "thursday": "全天", "friday": "全天", "saturday": "全天", "sunday": "全天", + } + b, _ := json.Marshal(hours) + return string(b) +} + +// GetOrCreateTenantSettings 读取租户设置;首次访问时以默认值落库。 +func GetOrCreateTenantSettings(tenantID uint) (*model.TenantSetting, error) { + var setting model.TenantSetting + err := model.DB.Where("tenant_id = ?", tenantID).First(&setting).Error + if err == nil { + return &setting, nil + } + + var tenant model.Tenant + _ = model.DB.First(&tenant, tenantID).Error + displayName := tenant.Name + if displayName == "" { + displayName = "在线客服" + } + setting = model.TenantSetting{ + TenantID: tenantID, + DisplayName: displayName, + AgentNickname: DefaultAgentNickname, + Timezone: defaultTimezone, + WelcomeMessage: DefaultWelcomeMessage, + WelcomeMessagesJSON: defaultWelcomeMessagesJSON(), + OfflinePrompt: DefaultOfflinePrompt, + WorkHoursJSON: defaultWorkHoursJSON(), + WorktimePrompt: DefaultWorktimePrompt, + NotifyNewSession: true, + NotifyOfflineLeave: true, + NotifyDailyReport: false, + AssignStrategy: AssignStrategyLeastLoad, + MaxActivePerAgent: 0, + } + if err := model.DB.Create(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func ParseWorkHours(raw string) map[string]string { + result := map[string]string{} + if strings.TrimSpace(raw) == "" { + _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) + return result + } + if err := json.Unmarshal([]byte(raw), &result); err != nil { + _ = json.Unmarshal([]byte(defaultWorkHoursJSON()), &result) + } + return result +} + +// NormalizeWorkHourSlot 规范化单日工作时间配置。 +// 允许:空/休息(→ 空)、全天、HH:MM-HH:MM(支持跨午夜,如 22:00-06:00)。 +func NormalizeWorkHourSlot(val string) (string, error) { + val = strings.TrimSpace(val) + if val == "" || val == "休息" { + return "", nil + } + if val == "全天" { + return "全天", nil + } + // 仅允许一个区间:HH:MM-HH:MM + parts := strings.Split(val, "-") + if len(parts) != 2 { + return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00 / 全天 / 空") + } + startS := strings.TrimSpace(parts[0]) + endS := strings.TrimSpace(parts[1]) + start, err1 := time.Parse("15:04", startS) + end, err2 := time.Parse("15:04", endS) + if err1 != nil || err2 != nil { + return "", fmt.Errorf("工作时间格式无效,请使用 09:00-18:00(24 小时制)") + } + return start.Format("15:04") + "-" + end.Format("15:04"), nil +} + +// slotCoversLocal 判断 slot(全天 / HH:MM-HH:MM)是否覆盖 local 时刻。 +func slotCoversLocal(slot string, local time.Time, loc *time.Location) bool { + slot = strings.TrimSpace(slot) + if slot == "" || slot == "休息" { + return false + } + if slot == "全天" { + return true + } + parts := strings.Split(slot, "-") + if len(parts) != 2 { + // 非法配置 fail-open,避免因脏数据整站拒接 + return true + } + start, err1 := time.ParseInLocation("15:04", strings.TrimSpace(parts[0]), loc) + end, err2 := time.ParseInLocation("15:04", strings.TrimSpace(parts[1]), loc) + if err1 != nil || err2 != nil { + return true + } + startAt := time.Date(local.Year(), local.Month(), local.Day(), start.Hour(), start.Minute(), 0, 0, loc) + endAt := time.Date(local.Year(), local.Month(), local.Day(), end.Hour(), end.Minute(), 0, 0, loc) + if !endAt.After(startAt) { + // 跨午夜:当前 >= start 或 < end + return !local.Before(startAt) || local.Before(endAt) + } + return !local.Before(startAt) && local.Before(endAt) +} + +// IsWithinWorkHours 根据租户工作时间配置判断当前是否在工作时段。 +// 未配置或某日为空视为休息;时段格式 HH:MM-HH:MM(可跨午夜)。 +func IsWithinWorkHours(setting *model.TenantSetting, now time.Time) bool { + if setting == nil { + return true + } + loc, err := time.LoadLocation(setting.Timezone) + if err != nil || setting.Timezone == "" { + loc, _ = time.LoadLocation(defaultTimezone) + } + local := now.In(loc) + hours := ParseWorkHours(setting.WorkHoursJSON) + var key string + switch local.Weekday() { + case time.Monday: + key = "monday" + case time.Tuesday: + key = "tuesday" + case time.Wednesday: + key = "wednesday" + case time.Thursday: + key = "thursday" + case time.Friday: + key = "friday" + case time.Saturday: + key = "saturday" + default: + key = "sunday" + } + return slotCoversLocal(hours[key], local, loc) +} + +// SettingsResponse 组装设置响应体。 +func SettingsResponse(setting *model.TenantSetting) map[string]interface{} { + strategy := NormalizeAssignStrategy(setting.AssignStrategy) + maxActive := setting.MaxActivePerAgent + if maxActive < 0 { + maxActive = 0 + } + welcomeSegs := ResolveWelcomeSegments(setting) + return map[string]interface{}{ + "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, + } +} + +// SettingsUpdateReq 设置更新请求(pointer 字段表示是否提交该字段)。 +type SettingsUpdateReq struct { + DisplayName *string `json:"display_name"` + AgentNickname *string `json:"agent_nickname"` + Timezone *string `json:"timezone"` + WelcomeMessage *string `json:"welcome_message"` + WelcomeMessages *[]WelcomeSegment `json:"welcome_messages"` + OfflinePrompt *string `json:"offline_prompt"` + WorkHours map[string]string `json:"work_hours"` + WorktimePrompt *string `json:"worktime_prompt"` + NotifyNewSession *bool `json:"notify_new_session"` + NotifyOfflineLeave *bool `json:"notify_offline_leave"` + NotifyDailyReport *bool `json:"notify_daily_report"` + AssignStrategy *string `json:"assign_strategy"` + MaxActivePerAgent *int `json:"max_active_per_agent"` +} + +// TouchesAssignRule / TouchesAutoReply / TouchesWorktime / TouchesNotification +// 供 handler 按字段分区校验权限。 +func (r *SettingsUpdateReq) TouchesAssignRule() bool { + return r.AssignStrategy != nil || r.MaxActivePerAgent != nil +} + +func (r *SettingsUpdateReq) TouchesAutoReply() bool { + return r.WelcomeMessage != nil || r.WelcomeMessages != nil || r.OfflinePrompt != nil +} + +func (r *SettingsUpdateReq) TouchesWorktime() bool { + return r.WorkHours != nil || r.WorktimePrompt != nil +} + +func (r *SettingsUpdateReq) TouchesNotification() bool { + return r.NotifyNewSession != nil || r.NotifyOfflineLeave != nil || r.NotifyDailyReport != nil +} + +// UpdateTenantSettings 校验并持久化租户设置,返回刷新后的响应体。 +func UpdateTenantSettings(tenantID uint, req *SettingsUpdateReq) (map[string]interface{}, error) { + setting, err := GetOrCreateTenantSettings(tenantID) + if err != nil { + return nil, Internal("加载设置失败") + } + + updates := map[string]interface{}{} + if req.DisplayName != nil { + name := strings.TrimSpace(*req.DisplayName) + if name == "" || len([]rune(name)) > 100 { + return nil, BadRequest("租户显示名称无效") + } + updates["display_name"] = name + // 同步更新租户主表名称(唯一约束下若冲突则仅更新 setting) + var conflict int64 + model.DB.Model(&model.Tenant{}).Where("name = ? AND id <> ?", name, tenantID).Count(&conflict) + if conflict == 0 { + model.DB.Model(&model.Tenant{}).Where("id = ?", tenantID).Update("name", name) + } + } + if req.AgentNickname != nil { + nick := strings.TrimSpace(*req.AgentNickname) + if len([]rune(nick)) > 50 { + return nil, BadRequest("客服昵称过长") + } + updates["agent_nickname"] = nick + } + if req.Timezone != nil { + tz := strings.TrimSpace(*req.Timezone) + if tz == "" { + tz = defaultTimezone + } + if _, err := time.LoadLocation(tz); err != nil { + return nil, BadRequest("时区无效") + } + updates["timezone"] = tz + } + // 优先多段欢迎语;若仅传旧 welcome_message 则同步为单段文本 + if req.WelcomeMessages != nil { + segs, firstText, err := NormalizeWelcomeSegments(*req.WelcomeMessages) + if err != nil { + return nil, BadRequest(err.Error()) + } + b, err := json.Marshal(segs) + if err != nil { + return nil, BadRequest("欢迎语序列化失败") + } + updates["welcome_messages_json"] = string(b) + updates["welcome_message"] = firstText + } else if req.WelcomeMessage != nil { + msg := strings.TrimSpace(*req.WelcomeMessage) + if len([]rune(msg)) > 500 { + return nil, BadRequest("欢迎语不能超过 500 字") + } + updates["welcome_message"] = msg + // 同步为单段,避免旧字段与 JSON 不一致 + if msg == "" { + updates["welcome_messages_json"] = defaultWelcomeMessagesJSON() + updates["welcome_message"] = DefaultWelcomeMessage + } else { + b, _ := json.Marshal([]WelcomeSegment{{Type: "text", Content: msg}}) + updates["welcome_messages_json"] = string(b) + } + } + if req.OfflinePrompt != nil { + msg := strings.TrimSpace(*req.OfflinePrompt) + if len([]rune(msg)) > 500 { + return nil, BadRequest("离线提示不能超过 500 字") + } + updates["offline_prompt"] = msg + } + if req.WorktimePrompt != nil { + msg := strings.TrimSpace(*req.WorktimePrompt) + if len([]rune(msg)) > 500 { + return nil, BadRequest("非工作时间提示不能超过 500 字") + } + updates["worktime_prompt"] = msg + } + if req.WorkHours != nil { + normalized := map[string]string{} + for _, key := range weekdayKeys { + slot, err := NormalizeWorkHourSlot(req.WorkHours[key]) + if err != nil { + return nil, BadRequest(err.Error()) + } + normalized[key] = slot + } + b, err := json.Marshal(normalized) + if err != nil { + return nil, BadRequest("工作时间序列化失败") + } + updates["work_hours_json"] = string(b) + } + if req.NotifyNewSession != nil { + updates["notify_new_session"] = *req.NotifyNewSession + } + if req.NotifyOfflineLeave != nil { + updates["notify_offline_leave"] = *req.NotifyOfflineLeave + } + if req.NotifyDailyReport != nil { + updates["notify_daily_report"] = *req.NotifyDailyReport + } + if req.AssignStrategy != nil { + raw := strings.TrimSpace(*req.AssignStrategy) + if raw != AssignStrategyLeastLoad && raw != AssignStrategyRoundRobin { + return nil, BadRequest("分配策略无效,可选 least_load / round_robin") + } + updates["assign_strategy"] = raw + } + if req.MaxActivePerAgent != nil { + if *req.MaxActivePerAgent < 0 || *req.MaxActivePerAgent > 200 { + return nil, BadRequest("最大并发需为 0–200(0 表示不限制)") + } + updates["max_active_per_agent"] = *req.MaxActivePerAgent + } + if len(updates) == 0 { + return nil, BadRequest("没有可更新字段") + } + + if err := model.DB.Model(setting).Updates(updates).Error; err != nil { + return nil, Internal("保存失败") + } + _ = model.DB.Where("tenant_id = ?", tenantID).First(setting).Error + return SettingsResponse(setting), nil +} diff --git a/server/internal/handler/settings_workhours_test.go b/server/internal/service/tenant_settings_test.go similarity index 54% rename from server/internal/handler/settings_workhours_test.go rename to server/internal/service/tenant_settings_test.go index 1b16331..bd080d4 100644 --- a/server/internal/handler/settings_workhours_test.go +++ b/server/internal/service/tenant_settings_test.go @@ -1,4 +1,4 @@ -package handler +package service import ( "testing" @@ -7,6 +7,67 @@ import ( "kefu-cloud/server/internal/model" ) +func TestNormalizeWelcomeSegments(t *testing.T) { + SetImagePublicBase("http://localhost:9100/kefu") + + _, _, err := NormalizeWelcomeSegments(nil) + if err == nil { + t.Fatal("空列表应失败") + } + + ok, first, err := NormalizeWelcomeSegments([]WelcomeSegment{ + {Type: "text", Content: " 你好 "}, + {Type: "image", Content: "http://localhost:9100/kefu/tenants/1/welcome/a.webp"}, + {Type: "text", Content: "请描述问题"}, + }) + if err != nil { + t.Fatalf("合法多段应通过: %v", err) + } + if len(ok) != 3 || ok[0].Content != "你好" || first != "你好" { + t.Fatalf("规范化结果异常: %+v first=%s", ok, first) + } + + _, _, err = NormalizeWelcomeSegments([]WelcomeSegment{ + {Type: "image", Content: "https://evil.example/x.png"}, + }) + if err == nil { + t.Fatal("非白名单图片 URL 应失败") + } + + tooMany := make([]WelcomeSegment, MaxWelcomeSegments+1) + for i := range tooMany { + tooMany[i] = WelcomeSegment{Type: "text", Content: "x"} + } + if _, _, err := NormalizeWelcomeSegments(tooMany); err == nil { + t.Fatal("超过段数上限应失败") + } +} + +func TestResolveWelcomeSegmentsCompat(t *testing.T) { + // 仅有旧字段 + s1 := &model.TenantSetting{WelcomeMessage: "旧欢迎语"} + segs := ResolveWelcomeSegments(s1) + if len(segs) != 1 || segs[0].Type != "text" || segs[0].Content != "旧欢迎语" { + t.Fatalf("旧字段兼容失败: %+v", segs) + } + + // 优先 JSON + s2 := &model.TenantSetting{ + WelcomeMessage: "旧", + WelcomeMessagesJSON: `[{"type":"text","content":"新1"},{"type":"image","content":"http://x/a.webp"}]`, + } + segs = ResolveWelcomeSegments(s2) + if len(segs) != 2 || segs[0].Content != "新1" { + t.Fatalf("JSON 优先失败: %+v", segs) + } + + // 空 → 默认 + segs = ResolveWelcomeSegments(&model.TenantSetting{}) + if len(segs) != 1 || segs[0].Content != DefaultWelcomeMessage { + t.Fatalf("默认欢迎语失败: %+v", segs) + } +} + func TestNormalizeWorkHourSlot(t *testing.T) { cases := []struct { in string @@ -25,18 +86,18 @@ func TestNormalizeWorkHourSlot(t *testing.T) { {"09:00-18:00-extra", "", true}, } for _, tc := range cases { - got, err := normalizeWorkHourSlot(tc.in) + got, err := NormalizeWorkHourSlot(tc.in) if tc.wantErr { if err == nil { - t.Fatalf("normalizeWorkHourSlot(%q) 期望错误,got=%q", tc.in, got) + t.Fatalf("NormalizeWorkHourSlot(%q) 期望错误,got=%q", tc.in, got) } continue } if err != nil { - t.Fatalf("normalizeWorkHourSlot(%q) 意外错误: %v", tc.in, err) + t.Fatalf("NormalizeWorkHourSlot(%q) 意外错误: %v", tc.in, err) } if got != tc.want { - t.Fatalf("normalizeWorkHourSlot(%q)=%q, want %q", tc.in, got, tc.want) + t.Fatalf("NormalizeWorkHourSlot(%q)=%q, want %q", tc.in, got, tc.want) } } } @@ -102,22 +163,22 @@ func TestIsWithinWorkHoursCustomRange(t *testing.T) { // 周一 10:00 → 在 09:30-12:00 mon := time.Date(2026, 3, 16, 10, 0, 0, 0, loc) - if !isWithinWorkHours(setting, mon) { + if !IsWithinWorkHours(setting, mon) { t.Fatal("周一 10:00 应在自定义时段内") } // 周一 14:00 → 不在 monPM := time.Date(2026, 3, 16, 14, 0, 0, 0, loc) - if isWithinWorkHours(setting, monPM) { + if IsWithinWorkHours(setting, monPM) { t.Fatal("周一 14:00 不应在工作时间") } // 周三休息 wed := time.Date(2026, 3, 18, 10, 0, 0, 0, loc) - if isWithinWorkHours(setting, wed) { + if IsWithinWorkHours(setting, wed) { t.Fatal("周三应休息") } // 周四跨午夜 23:00 thu := time.Date(2026, 3, 19, 23, 0, 0, 0, loc) - if !isWithinWorkHours(setting, thu) { + if !IsWithinWorkHours(setting, thu) { t.Fatal("周四 23:00 应在跨午夜时段") } } diff --git a/server/internal/service/util.go b/server/internal/service/util.go new file mode 100644 index 0000000..f130908 --- /dev/null +++ b/server/internal/service/util.go @@ -0,0 +1,59 @@ +package service + +import ( + "strings" + + "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/ws" +) + +// UniqueUint 去重并过滤零值。 +func UniqueUint(ids []uint) []uint { + seen := make(map[uint]struct{}, len(ids)) + out := make([]uint, 0, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +// UserDisplayName 坐席展示名(昵称优先,其次用户名)。 +func UserDisplayName(userID uint) string { + var user model.User + if err := model.DB.Select("id", "nickname", "username").First(&user, userID).Error; err != nil { + return "未知坐席" + } + if strings.TrimSpace(user.Nickname) != "" { + return user.Nickname + } + if strings.TrimSpace(user.Username) != "" { + return user.Username + } + return "未知坐席" +} + +// BroadcastSessionMessage 会话新消息广播(坐席 + 访客)。 +func BroadcastSessionMessage(session *model.Session, message model.Message) { + payload, err := ws.NewEvent("message", session.ID, message) + if err == nil { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } +} + +// BroadcastSessionUpdate 会话状态变更广播:坐席列表需要刷新;访客也必须收到 +// ended/active,才能弹出评价或更新接待状态。 +func BroadcastSessionUpdate(session *model.Session) { + payload, err := ws.NewEvent("session_updated", session.ID, map[string]interface{}{"status": session.Status}) + if err != nil { + return + } + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + ws.DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload) +} diff --git a/server/internal/service/widget.go b/server/internal/service/widget.go new file mode 100644 index 0000000..35207fe --- /dev/null +++ b/server/internal/service/widget.go @@ -0,0 +1,512 @@ +package service + +import ( + "errors" + "regexp" + "strings" + "time" + + "gorm.io/gorm" + "kefu-cloud/server/internal/model" + "kefu-cloud/server/internal/ws" +) + +var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`) +var phonePattern = regexp.MustCompile(`^1[3-9]\d{9}$`) +var emailPattern = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + +// VisitorMeta 访客环境信息(由 handler 从请求解析,service 不感知 HTTP)。 +type VisitorMeta struct { + IP string + Region string + UserAgent string +} + +// WidgetSession 校验访客凭证并加载会话。 +func WidgetSession(sessionID uint, token string) (*model.Session, error) { + var session model.Session + if err := model.DB.First(&session, sessionID).Error; err != nil { + return nil, NotFound("会话不存在") + } + if !model.VerifyVisitorToken(&session, token) { + return nil, &Error{Status: 401, Message: "访客会话凭证无效"} + } + return &session, nil +} + +// WidgetInitInput 访客初始化入参。 +type WidgetInitInput struct { + ChannelKey string + VisitorName string + DeviceID string + PageURL string + PageTitle string + Referrer string + Meta VisitorMeta +} + +// WidgetInit 访客进线:渠道校验、客户/会话创建、黑名单拦截、自动分配与欢迎语组装。 +func WidgetInit(in WidgetInitInput) (map[string]interface{}, error) { + if !channelKeyPattern.MatchString(in.ChannelKey) { + return nil, BadRequest("渠道标识无效") + } + + var channel model.Channel + if err := model.DB. + Where("type = ? AND status = ? AND script_code LIKE ?", "web", "enabled", "%data-id=\""+in.ChannelKey+"\"%"). + First(&channel).Error; err != nil { + return nil, NotFound("渠道不存在或已关闭") + } + + name := strings.TrimSpace(in.VisitorName) + if name == "" { + name = "访客" + } + + // 创建或查找客户 + var customer model.Customer + model.DB.Where("tenant_id = ? AND name = ? AND phone = ''", channel.TenantID, name).First(&customer) + if customer.ID == 0 { + customer = model.Customer{ + TenantID: channel.TenantID, + Name: name, + Source: "网页", + Status: "online", + } + model.DB.Create(&customer) + } + + visitorToken, visitorTokenHash, err := model.NewVisitorToken() + if err != nil { + return nil, Internal("创建访客会话失败") + } + + deviceKey := NormalizeDeviceKey(in.DeviceID, in.Meta.UserAgent) + // 黑名单拦截(IP / 设备) + if blocked, blKind, blReason := CheckVisitorBlacklist(channel.TenantID, in.Meta.IP, deviceKey); blocked { + kindLabel := "IP" + if blKind == "device" { + kindLabel = "设备" + } + msg := "您暂时无法使用在线客服" + if strings.TrimSpace(blReason) != "" { + msg = msg + "(" + kindLabel + "限制)" + } + return nil, Forbidden(msg) + } + now := time.Now() + + // 创建会话 + session := model.Session{ + TenantID: channel.TenantID, + ChannelID: channel.ID, + CustomerID: customer.ID, + VisitorTokenHash: visitorTokenHash, + VisitorIP: in.Meta.IP, + VisitorRegion: in.Meta.Region, + UserAgent: in.Meta.UserAgent, + DeviceKey: deviceKey, + LandingURL: in.PageURL, + LandingTitle: in.PageTitle, + Referrer: in.Referrer, + CurrentURL: in.PageURL, + CurrentTitle: in.PageTitle, + LastSeenAt: &now, + Status: "waiting", + Priority: "normal", + } + if err := model.DB.Create(&session).Error; err != nil { + return nil, Internal("创建会话失败") + } + // 首条浏览轨迹 + if in.PageURL != "" { + _ = model.DB.Create(&model.VisitorPageView{ + SessionID: session.ID, + TenantID: channel.TenantID, + URL: in.PageURL, + Title: in.PageTitle, + EnteredAt: now, + }).Error + } + model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", customer.ID, channel.TenantID). + Updates(map[string]interface{}{"conversation_count": gorm.Expr("conversation_count + 1"), "last_contact_at": time.Now()}) + + onlineCount, err := CountOnlineAgents(channel.TenantID) + if err != nil { + return nil, Internal("查询在线客服失败") + } + + setting, _ := GetOrCreateTenantSettings(channel.TenantID) + withinWorkHours := IsWithinWorkHours(setting, time.Now()) + // 非工作时间不自动分配,走留言/排队提示 + canServeNow := onlineCount > 0 && withinWorkHours + + var assignedAgent *model.User + if canServeNow { + assignedAgent, err = TryAutoAssign(&session) + if err != nil { + return nil, Internal("自动分配失败") + } + } + + if payload, err := ws.NewEvent("session_created", session.ID, session); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + + welcomeSegs := ResolveWelcomeSegments(setting) + welcome := FirstWelcomeText(welcomeSegs) + offlinePrompt := DefaultOfflinePrompt + agentNickname := DefaultAgentNickname + displayName := "在线客服" + worktimePrompt := DefaultWorktimePrompt + if setting != nil { + if setting.OfflinePrompt != "" { + offlinePrompt = setting.OfflinePrompt + } + if setting.AgentNickname != "" { + agentNickname = setting.AgentNickname + } + if setting.DisplayName != "" { + displayName = setting.DisplayName + } + if setting.WorktimePrompt != "" { + worktimePrompt = setting.WorktimePrompt + } + } + // 非工作时间优先展示非工作时间提示 + if !withinWorkHours { + offlinePrompt = worktimePrompt + } + + resp := map[string]interface{}{ + "session_id": session.ID, + "customer_id": customer.ID, + "channel_id": channel.ID, + "tenant_id": channel.TenantID, + "visitor_token": visitorToken, + "agents_online": canServeNow, + "online_agent_count": onlineCount, + "within_work_hours": withinWorkHours, + "session_status": session.Status, + "welcome_message": welcome, + "welcome_messages": welcomeSegs, + "display_name": displayName, + "agent_nickname": agentNickname, + } + if !canServeNow { + resp["offline_prompt"] = offlinePrompt + } + if assignedAgent != nil { + resp["agent_id"] = assignedAgent.ID + resp["agent_name"] = assignedAgent.Nickname + } else if canServeNow { + resp["agent_name"] = agentNickname + } + resp["landing_url"] = session.LandingURL + resp["current_url"] = session.CurrentURL + + return resp, nil +} + +// WidgetPageView 访客换页上报:与当前页相同只刷新活跃时间/标题,否则插入轨迹并裁剪。 +func WidgetPageView(session *model.Session, pageURL, pageTitle string) (interface{}, error) { + if session.Status == "ended" || session.Status == "archived" { + return nil, Conflict("会话已结束") + } + if pageURL == "" { + return nil, BadRequest("页面地址无效") + } + now := time.Now() + + // 与当前页相同:只刷新活跃时间/标题,不插重复轨迹 + if strings.TrimSpace(session.CurrentURL) == pageURL { + updates := map[string]interface{}{ + "last_seen_at": now, + } + titleFixed := false + if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) { + updates["current_title"] = pageTitle + // 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title) + var last model.VisitorPageView + if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL). + Order("entered_at desc").First(&last).Error; err == nil { + _ = model.DB.Model(&last).Update("title", pageTitle).Error + titleFixed = true + if payload, err := ws.NewEvent("page_view", session.ID, map[string]interface{}{ + "id": last.ID, + "session_id": last.SessionID, + "url": pageURL, + "title": pageTitle, + "entered_at": last.EnteredAt, + "title_fix": true, + }); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + } + } + _ = model.DB.Model(session).Updates(updates).Error + return map[string]interface{}{"deduped": true, "title_updated": titleFixed}, nil + } + + pv := model.VisitorPageView{ + SessionID: session.ID, + TenantID: session.TenantID, + URL: pageURL, + Title: pageTitle, + EnteredAt: now, + } + if err := model.DB.Create(&pv).Error; err != nil { + return nil, Internal("记录失败") + } + _ = model.DB.Model(session).Updates(map[string]interface{}{ + "current_url": pageURL, + "current_title": pageTitle, + "last_seen_at": now, + }).Error + + // 控制单会话条数 + var count int64 + model.DB.Model(&model.VisitorPageView{}).Where("session_id = ?", session.ID).Count(&count) + if count > MaxPageViewsPerSession { + var oldest []model.VisitorPageView + model.DB.Where("session_id = ?", session.ID).Order("entered_at asc"). + Limit(int(count - MaxPageViewsPerSession)).Find(&oldest) + ids := make([]uint, 0, len(oldest)) + for _, o := range oldest { + ids = append(ids, o.ID) + } + if len(ids) > 0 { + model.DB.Where("id IN ?", ids).Delete(&model.VisitorPageView{}) + } + } + + if payload, err := ws.NewEvent("page_view", session.ID, map[string]interface{}{ + "id": pv.ID, + "session_id": pv.SessionID, + "url": pv.URL, + "title": pv.Title, + "entered_at": pv.EnteredAt, + }); err == nil { + // 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor, + // 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。 + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + + return pv, nil +} + +// WidgetHeartbeat 刷新访客 last_seen;会话已结束时仅返回结束标记。 +func WidgetHeartbeat(session *model.Session) (ended bool, lastSeen *time.Time, err error) { + if session.Status == "ended" || session.Status == "archived" { + return true, nil, nil + } + now := time.Now() + _ = model.DB.Model(session).Update("last_seen_at", now).Error + return false, &now, nil +} + +// WidgetSendMessage 访客发送消息;排队中会尝试自动分配并广播。 +func WidgetSendMessage(session *model.Session, msgType, content string) (*model.Message, error) { + if session.Status == "ended" || session.Status == "archived" { + return nil, Conflict("会话已结束,请重新发起咨询") + } + + custID := session.CustomerID + msg := model.Message{ + SessionID: session.ID, + SenderType: "visitor", + SenderID: &custID, + Content: content, + Type: msgType, + SentAt: time.Now(), + } + + if err := model.CreateMessage(&msg); err != nil { + return nil, Internal("发送失败") + } + // 发送后清空草稿 + _ = model.DB.Model(session).Updates(map[string]interface{}{ + "draft_text": "", + "draft_updated_at": time.Now(), + }).Error + model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) + + // 从已发送消息再提取一次联系方式 + if msgType == "text" { + go OnVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content) + } + + // 仍在排队时尝试自动分配(客服刚上线的场景) + if session.Status == "waiting" && session.AgentID == nil { + if _, err := TryAutoAssign(session); err == nil { + _ = model.DB.First(session, session.ID) + } + } + + if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { + if session.AgentID == nil { + // 等待会话的消息要通知全部客服,便于任一在线客服及时领取。 + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } else { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } + } + // 通知坐席清空草稿预览 + if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{ + "from": "visitor", + "text": "", + }); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload) + } + + return &msg, nil +} + +// WidgetLeaveMessageResult 离线留言结果。 +type WidgetLeaveMessageResult struct { + Message model.Message + SessionStatus string + AgentsOnline bool +} + +// WidgetLeaveMessage 无客服在线时提交留言并沉淀联系方式。 +func WidgetLeaveMessage(session *model.Session, content, name, phone, email string) (*WidgetLeaveMessageResult, error) { + if session.Status == "ended" || session.Status == "archived" { + return nil, Conflict("会话已结束,请重新发起咨询") + } + + // 若此刻已有客服在线,直接走自动分配,留言仍作为普通消息入库 + onlineCount, _ := CountOnlineAgents(session.TenantID) + if onlineCount > 0 && session.Status == "waiting" { + if _, err := TryAutoAssign(session); err == nil { + _ = model.DB.First(session, session.ID) + } + } + + updates := map[string]interface{}{"last_contact_at": time.Now()} + if name != "" { + updates["name"] = name + } + if phone != "" { + updates["phone"] = phone + } + if email != "" { + updates["email"] = email + } + if err := model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). + Updates(updates).Error; err != nil { + return nil, Internal("更新联系方式失败") + } + + custID := session.CustomerID + msg := model.Message{ + SessionID: session.ID, + SenderType: "visitor", + SenderID: &custID, + Content: content, + Type: "text", + SentAt: time.Now(), + } + if err := model.CreateMessage(&msg); err != nil { + return nil, Internal("留言失败") + } + + contactBits := make([]string, 0, 3) + if name != "" { + contactBits = append(contactBits, "姓名:"+name) + } + if phone != "" { + contactBits = append(contactBits, "手机:"+phone) + } + if email != "" { + contactBits = append(contactBits, "邮箱:"+email) + } + model.DB.Create(&model.SessionEvent{ + SessionID: session.ID, + OperatorID: 0, + Action: "offline_leave", + Detail: "离线留言 · " + strings.Join(contactBits, " · "), + }) + + if payload, err := ws.NewEvent("message", session.ID, msg); err == nil { + if session.AgentID == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } else { + ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload) + } + } + if payload, err := ws.NewEvent("session_updated", session.ID, session); err == nil { + ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + } + + return &WidgetLeaveMessageResult{ + Message: msg, + SessionStatus: session.Status, + AgentsOnline: onlineCount > 0, + }, nil +} + +// ValidateLeaveContact 校验留言联系方式(至少一项 + 格式)。 +func ValidateLeaveContact(name, phone, email string) error { + if phone == "" && email == "" { + return BadRequest("请至少填写手机号或邮箱,便于我们回复") + } + if phone != "" && !phonePattern.MatchString(phone) { + return BadRequest("手机号格式不正确") + } + if email != "" && !emailPattern.MatchString(email) { + return BadRequest("邮箱格式不正确") + } + if name != "" && (len([]rune(name)) < 2 || len([]rune(name)) > 50) { + return BadRequest("姓名需为 2 至 50 个字符") + } + return nil +} + +// WidgetSubmitRating 会话结束后提交星级评价(一次性)。 +func WidgetSubmitRating(session *model.Session, score int, text string) error { + if session.Status != "ended" { + return Conflict("会话结束后才能评价") + } + if session.SatisfactionScore != nil { + return Conflict("该会话已评价") + } + + if err := model.DB.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.Session{}).Where("id = ? AND satisfaction_score IS NULL", session.ID). + Updates(map[string]interface{}{"satisfaction_score": score, "satisfaction_text": text}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID). + Updates(map[string]interface{}{ + "satisfaction_sum": gorm.Expr("satisfaction_sum + ?", score), + "satisfaction_count": gorm.Expr("satisfaction_count + 1"), + }).Error + }); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return Conflict("该会话已评价") + } + return Internal("提交评价失败") + } + return nil +} + +// WidgetMessages 访客按 seq 增量拉取消息。 +func WidgetMessages(session *model.Session, afterSeq, limit int) (map[string]interface{}, error) { + page, err := SessionMessagePage(session.ID, afterSeq, limit) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "messages": page.Messages, + "after_seq": page.AfterSeq, + "max_seq": page.MaxSeq, + "has_more": page.HasMore, + "session_status": session.Status, + "satisfaction_score": session.SatisfactionScore, + }, nil +}