- handler 只保留请求绑定、权限检查与响应映射;业务校验顺序、状态码与文案逐字保留 - 新增 service.Error 语义错误,由 handler 统一映射 HTTP 响应 - session 875→361 行、widget 745→321 行、settings 540→73 行 - 自动分配/租户设置(工作时间、欢迎语)/消息校验/黑名单检查/联系方式提取随迁 service 包 - 相关单元测试随迁 service 包,go test ./... 全部通过
322 lines
9.5 KiB
Go
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})
|
|
}
|