package controller import ( "net/http" "strconv" "github.com/2930134478/AI-CS/backend/service" "github.com/2930134478/AI-CS/backend/utils" "github.com/gin-gonic/gin" ) // ConversationController 负责处理会话相关的 HTTP 请求。 type ConversationController struct { conversationService *service.ConversationService aiConfigService *service.AIConfigService // 用于获取开放的模型列表 users *service.UserService } // NewConversationController 创建 ConversationController 实例。 func NewConversationController( conversationService *service.ConversationService, aiConfigService *service.AIConfigService, users *service.UserService, ) *ConversationController { return &ConversationController{ conversationService: conversationService, aiConfigService: aiConfigService, users: users, } } type initConversationRequest struct { VisitorID uint `json:"visitor_id"` Website string `json:"website"` Referrer string `json:"referrer"` Browser string `json:"browser"` OS string `json:"os"` Language string `json:"language"` ChatMode string `json:"chat_mode"` // 对话模式:human(人工客服)、ai(AI客服) AIConfigID *uint `json:"ai_config_id"` // AI 配置 ID(访客选择的模型配置,AI 模式时必需) } type updateContactRequest struct { Email *string `json:"email"` Phone *string `json:"phone"` Notes *string `json:"notes"` } // InitConversation 为访客初始化或恢复会话。 func (cc *ConversationController) InitConversation(c *gin.Context) { var req initConversationRequest if err := c.ShouldBindJSON(&req); err != nil || req.VisitorID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) return } browser := req.Browser os := req.OS if browser == "" || os == "" { parsedBrowser, parsedOS := utils.ParseUserAgent(c.GetHeader("User-Agent")) if browser == "" { browser = parsedBrowser } if os == "" { os = parsedOS } } result, err := cc.conversationService.InitConversation(service.InitConversationInput{ VisitorID: req.VisitorID, Website: req.Website, Referrer: req.Referrer, Browser: browser, OS: os, Language: req.Language, IPAddress: utils.GetClientIP(c), ChatMode: req.ChatMode, AIConfigID: req.AIConfigID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "conversation_id": result.ConversationID, "status": result.Status, "access_token": result.AccessToken, }) } // InitInternalConversation 为当前客服创建一条新的内部对话(知识库测试)。需要 query user_id。 func (cc *ConversationController) InitInternalConversation(c *gin.Context) { if !requirePermission(c, cc.users, string(service.PermKBTest)) { return } userIDStr := c.Query("user_id") if userIDStr == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "需要 user_id"}) return } userID, err := strconv.ParseUint(userIDStr, 10, 32) if err != nil || userID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"}) return } result, err := cc.conversationService.InitInternalConversation(uint(userID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "conversation_id": result.ConversationID, "status": result.Status, }) } // GetPublicAIModels 获取所有开放的模型配置(供访客选择)。 func (cc *ConversationController) GetPublicAIModels(c *gin.Context) { modelType := c.DefaultQuery("model_type", "text") models, err := cc.aiConfigService.GetPublicModels(modelType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"models": models}) } // UpdateContactInfo 用于更新访客的联系信息(访客持 access_token,客服持 X-User-Id)。 func (cc *ConversationController) UpdateContactInfo(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"}) return } if _, ok := authorizeConversationAccess(c, cc.conversationService, cc.users, uint(id)); !ok { return } var req updateContactRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"}) return } if req.Email == nil && req.Phone == nil && req.Notes == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "至少提供一个需要更新的字段"}) return } result, err := cc.conversationService.UpdateConversationContact(service.UpdateConversationContactInput{ ConversationID: uint(id), Email: req.Email, Phone: req.Phone, Notes: req.Notes, }) if err != nil { if err == service.ErrConversationNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"}) } else { c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"}) } return } c.JSON(http.StatusOK, gin.H{ "email": result.Email, "phone": result.Phone, "notes": result.Notes, }) } // CloseConversation 客服关闭会话(进入历史/归档)。 // POST /conversations/:id/close func (cc *ConversationController) CloseConversation(c *gin.Context) { if !requirePermission(c, cc.users, string(service.PermChat)) { return } id, err := parseUintParam(c, "id") if err != nil || id == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"}) return } userID := getUserIDFromHeader(c) if err := cc.conversationService.CloseConversation(uint(id), userID); err != nil { if err == service.ErrConversationNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"}) return } c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // ListConversations 返回当前活跃会话的列表。type=internal 时返回该客服的内部对话(知识库测试)。 func (cc *ConversationController) ListConversations(c *gin.Context) { var userID uint if userIDStr := c.Query("user_id"); userIDStr != "" { if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil { userID = uint(parsed) } } conversationType := c.DefaultQuery("type", "visitor") status := c.DefaultQuery("status", "open") page := 1 pageSize := 50 if p := c.Query("page"); p != "" { if parsed, err := strconv.Atoi(p); err == nil { page = parsed } } if ps := c.Query("page_size"); ps != "" { if parsed, err := strconv.Atoi(ps); err == nil { pageSize = parsed } } var listResult *service.ConversationListResult var err error if conversationType == "internal" { if !requirePermission(c, cc.users, string(service.PermKBTest)) { return } if userID == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"}) return } listResult, err = cc.conversationService.ListInternalConversationsPaginated(userID, status, page, pageSize) } else { listResult, err = cc.conversationService.ListConversationsPaginated(userID, status, page, pageSize) } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"}) return } items := formatConversationListItems(listResult.Items) c.JSON(http.StatusOK, gin.H{ "items": items, "total": listResult.Total, "page": listResult.Page, "page_size": listResult.PageSize, "has_more": listResult.HasMore, "total_unread": listResult.TotalUnread, }) } func formatConversationListItems(conversations []service.ConversationSummary) []gin.H { items := make([]gin.H, 0, len(conversations)) for _, conv := range conversations { item := gin.H{ "id": conv.ID, "conversation_type": conv.ConversationType, "visitor_id": conv.VisitorID, "agent_id": conv.AgentID, "status": conv.Status, "chat_mode": conv.ChatMode, "created_at": formatTimeValue(conv.CreatedAt), "updated_at": formatTimeValue(conv.UpdatedAt), "unread_count": conv.UnreadCount, "has_participated": conv.HasParticipated, } if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" { item["last_seen_at"] = lastSeen } if conv.LastMessage != nil { item["last_message"] = gin.H{ "id": conv.LastMessage.ID, "content": conv.LastMessage.Content, "sender_is_agent": conv.LastMessage.SenderIsAgent, "message_type": conv.LastMessage.MessageType, "is_read": conv.LastMessage.IsRead, "read_at": formatTimePointer(conv.LastMessage.ReadAt), "created_at": formatTimeValue(conv.LastMessage.CreatedAt), } } items = append(items, item) } return items } // GetConversationDetail 返回会话的详细信息。 func (cc *ConversationController) GetConversationDetail(c *gin.Context) { id, err := parseUintParam(c, "id") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"}) return } detail, ok := authorizeConversationAccess(c, cc.conversationService, cc.users, uint(id)) if !ok { return } response := gin.H{ "id": detail.ID, "visitor_id": detail.VisitorID, "agent_id": detail.AgentID, "status": detail.Status, "website": detail.Website, "referrer": detail.Referrer, "browser": detail.Browser, "os": detail.OS, "language": detail.Language, "ip_address": detail.IPAddress, "location": detail.Location, "email": detail.Email, "phone": detail.Phone, "notes": detail.Notes, "created_at": formatTimeValue(detail.CreatedAt), "updated_at": formatTimeValue(detail.UpdatedAt), "unread_count": detail.UnreadCount, } if lastSeen := formatTimePointer(detail.LastSeen); lastSeen != "" { response["last_seen_at"] = lastSeen } if detail.LastMessage != nil { response["last_message"] = gin.H{ "id": detail.LastMessage.ID, "content": detail.LastMessage.Content, "sender_is_agent": detail.LastMessage.SenderIsAgent, "message_type": detail.LastMessage.MessageType, "is_read": detail.LastMessage.IsRead, "read_at": formatTimePointer(detail.LastMessage.ReadAt), "created_at": formatTimeValue(detail.LastMessage.CreatedAt), } } c.JSON(http.StatusOK, response) } // SearchConversations 根据关键字进行会话的模糊搜索。 func (cc *ConversationController) SearchConversations(c *gin.Context) { query := c.Query("q") if query == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"}) return } status := c.DefaultQuery("status", "open") convType := c.DefaultQuery("type", "visitor") // 从查询参数获取 user_id(可选,用于检查参与状态) var userID uint if userIDStr := c.Query("user_id"); userIDStr != "" { // 使用 strconv 解析查询参数(不是路径参数) if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil { userID = uint(parsed) } } conversations, err := cc.conversationService.SearchConversations(query, userID, status, convType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"}) return } items := make([]gin.H, 0, len(conversations)) for _, conv := range conversations { item := gin.H{ "id": conv.ID, "visitor_id": conv.VisitorID, "agent_id": conv.AgentID, "status": conv.Status, "created_at": formatTimeValue(conv.CreatedAt), "updated_at": formatTimeValue(conv.UpdatedAt), "unread_count": conv.UnreadCount, "has_participated": conv.HasParticipated, // 当前用户是否参与过该会话 } // 添加 last_seen_at 字段(用于判断在线状态) if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" { item["last_seen_at"] = lastSeen } if conv.LastMessage != nil { item["last_message"] = gin.H{ "id": conv.LastMessage.ID, "content": conv.LastMessage.Content, "sender_is_agent": conv.LastMessage.SenderIsAgent, "message_type": conv.LastMessage.MessageType, "is_read": conv.LastMessage.IsRead, "read_at": formatTimePointer(conv.LastMessage.ReadAt), "created_at": formatTimeValue(conv.LastMessage.CreatedAt), } } items = append(items, item) } c.JSON(http.StatusOK, items) } type putAutoCloseDaysBody struct { InactiveDays int `json:"inactive_days"` } // GetAutoCloseConversationDaysPolicy 读取自动关闭 stale 会话策略。 func (cc *ConversationController) GetAutoCloseConversationDaysPolicy(c *gin.Context) { if !requirePermission(c, cc.users, string(service.PermSettings)) { return } policy := cc.conversationService.GetAutoCloseConversationDaysPolicy() c.JSON(http.StatusOK, policy) } // PutAutoCloseConversationDaysPolicy 写入自动关闭 stale 会话天数(0=禁用)。 func (cc *ConversationController) PutAutoCloseConversationDaysPolicy(c *gin.Context) { if !requirePermission(c, cc.users, string(service.PermSettings)) { return } var body putAutoCloseDaysBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请求体无效"}) return } if err := cc.conversationService.SetAutoCloseConversationDaysPolicy(body.InactiveDays); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } policy := cc.conversationService.GetAutoCloseConversationDaysPolicy() c.JSON(http.StatusOK, gin.H{ "ok": true, "effective_days": policy.EffectiveDays, }) } // DeleteAutoCloseConversationDaysPolicy 删除数据库覆盖,恢复为 .env。 func (cc *ConversationController) DeleteAutoCloseConversationDaysPolicy(c *gin.Context) { if !requirePermission(c, cc.users, string(service.PermSettings)) { return } if err := cc.conversationService.ClearAutoCloseConversationDaysPolicy(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } policy := cc.conversationService.GetAutoCloseConversationDaysPolicy() c.JSON(http.StatusOK, gin.H{ "ok": true, "effective_days": policy.EffectiveDays, }) }