Files
kefu_cloud/server/internal/handler/widget.go
T
yml2213 1f92c35963 新增访客黑名单:支持拉黑 IP/设备并管理列表
工作台可按 IP 或设备拉黑并填写释放时间与原因;侧栏黑名单页查看与解除;进线自动拦截。
2026-07-19 00:43:55 +08:00

746 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"`
// 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"`
}
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)
deviceKey := normalizeDeviceKey(req.DeviceID, userAgent)
// 黑名单拦截(IP / 设备)
if blocked, blKind, blReason := checkVisitorBlacklist(channel.TenantID, visitorIP, deviceKey); blocked {
kindLabel := "IP"
if blKind == "device" {
kindLabel = "设备"
}
msg := "您暂时无法使用在线客服"
if strings.TrimSpace(blReason) != "" {
msg = msg + "" + kindLabel + "限制)"
}
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": msg})
return
}
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,
DeviceKey: deviceKey,
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()
// 与当前页相同:只刷新活跃时间/标题,不插重复轨迹
if strings.TrimSpace(session.CurrentURL) == pageURL {
updates := map[string]interface{}{
"last_seen_at": now,
}
titleFixed := false
if pageTitle != "" && pageTitle != strings.TrimSpace(session.CurrentTitle) {
updates["current_title"] = pageTitle
// 同步最新一条轨迹的标题(纠正「慢一页」时写入的旧 title)
var last model.VisitorPageView
if err := model.DB.Where("session_id = ? AND url = ?", session.ID, pageURL).
Order("entered_at desc").First(&last).Error; err == nil {
_ = model.DB.Model(&last).Update("title", pageTitle).Error
titleFixed = true
if payload, err := ws.NewEvent("page_view", session.ID, gin.H{
"id": last.ID,
"session_id": last.SessionID,
"url": pageURL,
"title": pageTitle,
"entered_at": last.EnteredAt,
"title_fix": true,
}); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, payload)
}
}
}
_ = model.DB.Model(session).Updates(updates).Error
c.JSON(http.StatusOK, gin.H{"code": 0, "data": gin.H{"deduped": true, "title_updated": titleFixed}})
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 {
// 排队中 AgentID 为空时,BroadcastToSessionStaff 只会推给 admin/supervisor
// 一线坐席看不到实时轨迹;轨迹对租户内工作人员统一广播。
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, 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(session).Updates(map[string]interface{}{
"draft_text": "",
"draft_updated_at": time.Now(),
}).Error
model.DB.Model(&model.Customer{}).Where("id = ? AND tenant_id = ?", session.CustomerID, session.TenantID).Update("last_contact_at", time.Now())
// 从已发送消息再提取一次联系方式
if req.Type == "text" {
go onVisitorDraftContacts(session.TenantID, session.CustomerID, session.ID, content)
}
// 仍在排队时尝试自动分配(客服刚上线的场景)
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)
}
}
// 通知坐席清空草稿预览
if clearPayload, err := ws.NewEvent("input_draft", session.ID, map[string]interface{}{
"from": "visitor",
"text": "",
}); err == nil {
ws.DefaultHub.BroadcastToTenantStaff(session.TenantID, clearPayload)
}
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,
},
})
}