Files
kefu_cloud/server/internal/handler/widget.go
T
yml2213 8b9f3d710f refactor(server): 会话/访客/设置业务逻辑下沉 internal/service 层,handler 瘦身
- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留
- 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应
- session 875→361 行、widget 745→321 行、settings 540→73 行
- 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包
- 相关单元测试随迁 service 包,go test ./... 全部通过
2026-09-05 14:39:34 +08:00

322 lines
9.5 KiB
Go

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})
}