package handler import ( "net/http" "strconv" "strings" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/service" "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"` // DeviceID 访客端持久设备指纹(localStorage),用于设备级拉黑 DeviceID string `json:"device_id" form:"device_id"` // 宿主页信息(由 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"` } 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") } // 宿主页信息:body 优先,query 兜底 pageURL := service.SanitizePageURL(req.PageURL) if pageURL == "" { pageURL = service.SanitizePageURL(c.Query("page_url")) } pageTitle := service.SanitizePageTitle(req.PageTitle) if pageTitle == "" { pageTitle = service.SanitizePageTitle(c.Query("page_title")) } referrer := service.SanitizePageURL(req.Referrer) if referrer == "" { referrer = service.SanitizePageURL(c.Query("referrer")) } 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 { respondServiceError(c, err) return } 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 } session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if err != nil { respondServiceError(c, err) return } data, err := service.WidgetPageView(session, service.SanitizePageURL(req.PageURL), service.SanitizePageTitle(req.PageTitle)) if err != nil { respondServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"code": 0, "data": data}) } // 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 } session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if err != nil { respondServiceError(c, err) return } 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 } 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 { if token := c.GetHeader("X-Visitor-Token"); token != "" { return token } return bodyToken } 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 := service.ValidateMessageContent(req.Type, req.Content) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if err != nil { respondServiceError(c, err) return } msg, err := service.WidgetSendMessage(session, req.Type, content) if err != nil { respondServiceError(c, err) return } 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 := service.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 err := service.ValidateLeaveContact(name, phone, email); err != nil { respondServiceError(c, err) return } session, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if err != nil { respondServiceError(c, err) return } result, err := service.WidgetLeaveMessage(session, content, name, phone, email) if err != nil { respondServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{ "code": 0, "data": gin.H{ "message": result.Message, "session_status": result.SessionStatus, "agents_online": result.AgentsOnline, }, }) } 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, 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) 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, err := service.WidgetSession(req.SessionID, visitorTokenFromRequest(c, req.VisitorToken)) if err != nil { respondServiceError(c, err) return } if err := service.WidgetSubmitRating(session, req.Score, req.Text); err != nil { respondServiceError(c, err) 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, err := service.WidgetSession(uint(sessionID), visitorTokenFromRequest(c, "")) if err != nil { respondServiceError(c, err) 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 } } data, err := service.WidgetMessages(session, afterSeq, limit) if err != nil { respondServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"code": 0, "data": data}) }