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/ws" ) type WidgetHandler struct{} func NewWidgetHandler() *WidgetHandler { return &WidgetHandler{} } type WidgetInitReq struct { ChannelKey string `json:"channel_key" form:"channel_key"` VisitorName string `json:"visitor_name"` // 宿主页信息(由 widget.js / 前端上报) PageURL string `json:"page_url" form:"page_url"` PageTitle string `json:"page_title" form:"page_title"` Referrer string `json:"referrer" form:"referrer"` } type WidgetPageViewReq struct { SessionID uint `json:"session_id" binding:"required"` VisitorToken string `json:"visitor_token"` PageURL string `json:"page_url" binding:"required"` PageTitle string `json:"page_title"` } type WidgetHeartbeatReq struct { SessionID uint `json:"session_id" binding:"required"` VisitorToken string `json:"visitor_token"` } type WidgetMessageReq struct { SessionID uint `json:"session_id" binding:"required"` Content string `json:"content" binding:"required"` Type string `json:"type"` VisitorToken string `json:"visitor_token"` } type WidgetRatingReq struct { SessionID uint `json:"session_id" binding:"required"` Score int `json:"score" binding:"required"` Text string `json:"text"` VisitorToken string `json:"visitor_token"` } type WidgetLeaveReq struct { SessionID uint `json:"session_id" binding:"required"` Content string `json:"content" binding:"required"` Name string `json:"name"` Phone string `json:"phone"` Email string `json:"email"` 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 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } if c.Request.Method == http.MethodPost && strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") { if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } } 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) pageURL := sanitizePageURL(req.PageURL) if pageURL == "" { // query 兜底 pageURL = sanitizePageURL(c.Query("page_url")) } pageTitle := sanitizePageTitle(req.PageTitle) if pageTitle == "" { pageTitle = sanitizePageTitle(c.Query("page_title")) } referrer := sanitizePageURL(req.Referrer) if referrer == "" { referrer = 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, 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) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询在线客服失败"}) 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}) } // PageView POST /api/widget/pageview — 访客换页上报 func (h *WidgetHandler) PageView(c *gin.Context) { var req WidgetPageViewReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } token := visitorTokenFromRequest(c, req.VisitorToken) session, ok := loadVisitorSession(c, req.SessionID, token) if !ok { return } if session.Status == "ended" || session.Status == "archived" { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束"}) return } pageURL := sanitizePageURL(req.PageURL) if pageURL == "" { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "页面地址无效"}) return } pageTitle := sanitizePageTitle(req.PageTitle) now := time.Now() // 与当前页相同则只刷新 last_seen,不重复插轨迹 if strings.TrimSpace(session.CurrentURL) == pageURL { _ = model.DB.Model(session).Updates(map[string]interface{}{ "last_seen_at": now, "current_title": pageTitle, }).Error c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true}}) 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 { ws.DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload) } c.JSON(http.StatusOK, gin.H{"code": 0, "data": pv}) } // Heartbeat POST /api/widget/heartbeat — 刷新 last_seen,供在线读秒 func (h *WidgetHandler) Heartbeat(c *gin.Context) { var req WidgetHeartbeatReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } token := visitorTokenFromRequest(c, req.VisitorToken) session, ok := loadVisitorSession(c, req.SessionID, token) if !ok { return } if session.Status == "ended" || session.Status == "archived" { 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}}) } func visitorTokenFromRequest(c *gin.Context, bodyToken string) string { if token := c.GetHeader("X-Visitor-Token"); token != "" { return token } 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 { 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 := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if !ok { return } if session.Status == "ended" || session.Status == "archived" { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"}) 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(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now()) // 仍在排队时尝试自动分配(客服刚上线的场景) 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) } } c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg}) } // LeaveMessage 无客服在线时提交留言并沉淀联系方式。 func (h *WidgetHandler) LeaveMessage(c *gin.Context) { var req WidgetLeaveReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } content, err := validateMessageContent("text", req.Content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } 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 个字符"}) return } session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if !ok { return } if session.Status == "ended" || session.Status == "archived" { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话已结束,请重新发起咨询"}) 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, }, }) } func visitorTokenFromWebSocket(c *gin.Context) string { protocols := strings.Split(c.GetHeader("Sec-WebSocket-Protocol"), ",") if len(protocols) == 2 && strings.TrimSpace(protocols[0]) == "kefu-visitor-v1" { return strings.TrimSpace(protocols[1]) } return "" } func (h *WidgetHandler) Connect(c *gin.Context) { sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64) if err != nil || sessionID == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) return } session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromWebSocket(c)) if !ok { return } client, err := ws.UpgradeVisitor(c.Writer, c.Request, session.TenantID, session.ID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "升级连接失败"}) return } ws.HandleWebSocket(client) } func (h *WidgetHandler) SubmitRating(c *gin.Context) { var req WidgetRatingReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } if req.Score < 1 || req.Score > 5 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "评分应为 1 至 5 星"}) return } session, ok := loadVisitorSession(c, req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if !ok { return } if session.Status != "ended" { c.JSON(http.StatusConflict, gin.H{"code": 409, "message": "会话结束后才能评价"}) 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": "感谢您的评价"}) } func (h *WidgetHandler) GetMessages(c *gin.Context) { sessionID, err := strconv.ParseUint(c.Query("session_id"), 10, 64) if err != nil || sessionID == 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "会话参数错误"}) return } session, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromRequest(c, "")) if !ok { return } afterSeq := 0 if raw := strings.TrimSpace(c.Query("after_seq")); raw != "" { parsed, err := strconv.Atoi(raw) if err != nil || parsed < 0 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "after_seq 无效"}) return } afterSeq = parsed } limit := 200 if raw := strings.TrimSpace(c.Query("limit")); raw != "" { if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 { if parsed > 500 { parsed = 500 } limit = parsed } } 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": "查询消息失败"}) 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, }, }) }