286 lines
8.6 KiB
Go
286 lines
8.6 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/gorm"
|
|
"kefu-sys/server/internal/middleware"
|
|
"kefu-sys/server/internal/model"
|
|
"kefu-sys/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"`
|
|
}
|
|
|
|
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"`
|
|
}
|
|
|
|
var channelKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{3,64}$`)
|
|
|
|
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 := 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
|
|
}
|
|
|
|
// 创建会话
|
|
session := model.Session{
|
|
TenantID: channel.TenantID,
|
|
ChannelID: channel.ID,
|
|
CustomerID: customer.ID,
|
|
VisitorTokenHash: visitorTokenHash,
|
|
Status: "waiting",
|
|
Priority: "normal",
|
|
}
|
|
if err := model.DB.Create(&session).Error; 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)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"code": 0,
|
|
"data": gin.H{
|
|
"session_id": session.ID,
|
|
"customer_id": customer.ID,
|
|
"channel_id": channel.ID,
|
|
"tenant_id": channel.TenantID,
|
|
"visitor_token": visitorToken,
|
|
},
|
|
})
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 如果是等待中的会话,更新为活跃
|
|
if session.Status == "waiting" {
|
|
if err := model.DB.Model(session).Update("status", "active").Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新会话失败"})
|
|
return
|
|
}
|
|
session.Status = "active"
|
|
}
|
|
|
|
custID := session.CustomerID
|
|
msg := model.Message{
|
|
SessionID: session.ID,
|
|
SenderType: "visitor",
|
|
SenderID: &custID,
|
|
Content: req.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 payload, err := ws.NewEvent("message", session.ID, msg); err == nil {
|
|
ws.DefaultHub.BroadcastToSession(session.TenantID, session.ID, session.AgentID, payload)
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": msg})
|
|
}
|
|
|
|
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
|
|
}
|
|
if _, ok := loadVisitorSession(c, uint(sessionID), visitorTokenFromRequest(c, "")); !ok {
|
|
return
|
|
}
|
|
|
|
var messages []model.Message
|
|
if err := model.DB.Where("session_id = ?", sessionID).Order("seq asc").Find(&messages).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "查询消息失败"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"code": 0, "data": messages})
|
|
}
|