689 lines
21 KiB
Go
689 lines
21 KiB
Go
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,
|
|
},
|
|
})
|
|
}
|