mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
493 lines
14 KiB
Go
493 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/2930134478/AI-CS/backend/models"
|
|
"github.com/2930134478/AI-CS/backend/repository"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// ConversationService 负责会话领域的业务编排。
|
|
type ConversationService struct {
|
|
conversations *repository.ConversationRepository
|
|
messages *repository.MessageRepository
|
|
aiConfigRepo *repository.AIConfigRepository // 用于验证 AI 配置
|
|
userRepo *repository.UserRepository // 用于查询用户设置
|
|
}
|
|
|
|
// NewConversationService 创建 ConversationService 实例。
|
|
func NewConversationService(
|
|
conversations *repository.ConversationRepository,
|
|
messages *repository.MessageRepository,
|
|
aiConfigRepo *repository.AIConfigRepository,
|
|
userRepo *repository.UserRepository,
|
|
) *ConversationService {
|
|
return &ConversationService{
|
|
conversations: conversations,
|
|
messages: messages,
|
|
aiConfigRepo: aiConfigRepo,
|
|
userRepo: userRepo,
|
|
}
|
|
}
|
|
|
|
// InitConversation 为访客创建或恢复会话。
|
|
func (s *ConversationService) InitConversation(input InitConversationInput) (*InitConversationResult, error) {
|
|
var (
|
|
conv *models.Conversation
|
|
err error
|
|
)
|
|
|
|
conv, err = s.conversations.FindOpenByVisitorID(input.VisitorID)
|
|
isNewConversation := false
|
|
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
now := time.Now()
|
|
chatMode := input.ChatMode
|
|
if chatMode == "" {
|
|
chatMode = "human" // 默认人工客服
|
|
}
|
|
|
|
// 如果是 AI 模式,验证 AI 配置
|
|
var aiConfigID *uint
|
|
if chatMode == "ai" {
|
|
if input.AIConfigID == nil || *input.AIConfigID == 0 {
|
|
return nil, errors.New("AI 模式需要选择模型配置")
|
|
}
|
|
// 验证配置是否存在且开放
|
|
config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
|
|
if err != nil {
|
|
return nil, errors.New("模型配置不存在")
|
|
}
|
|
if !config.IsPublic {
|
|
return nil, errors.New("该模型未开放给访客使用")
|
|
}
|
|
if !config.IsActive {
|
|
return nil, errors.New("该模型配置已禁用")
|
|
}
|
|
aiConfigID = input.AIConfigID
|
|
}
|
|
|
|
conv = &models.Conversation{
|
|
ConversationType: "visitor",
|
|
VisitorID: input.VisitorID,
|
|
Status: "open",
|
|
Website: input.Website,
|
|
Referrer: input.Referrer,
|
|
Browser: input.Browser,
|
|
OS: input.OS,
|
|
Language: input.Language,
|
|
IPAddress: input.IPAddress,
|
|
LastSeenAt: &now,
|
|
ChatMode: chatMode,
|
|
AIConfigID: aiConfigID,
|
|
}
|
|
if err := s.conversations.Create(conv); err != nil {
|
|
return nil, err
|
|
}
|
|
isNewConversation = true
|
|
} else {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// 恢复已存在的对话
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"last_seen_at": &now,
|
|
}
|
|
|
|
// 更新访客信息(如果之前没有)
|
|
if input.Website != "" && conv.Website == "" {
|
|
updates["website"] = input.Website
|
|
}
|
|
if input.Referrer != "" && conv.Referrer == "" {
|
|
updates["referrer"] = input.Referrer
|
|
}
|
|
if input.Browser != "" && conv.Browser == "" {
|
|
updates["browser"] = input.Browser
|
|
}
|
|
if input.OS != "" && conv.OS == "" {
|
|
updates["os"] = input.OS
|
|
}
|
|
if input.Language != "" && conv.Language == "" {
|
|
updates["language"] = input.Language
|
|
}
|
|
if input.IPAddress != "" && conv.IPAddress == "" {
|
|
updates["ip_address"] = input.IPAddress
|
|
}
|
|
|
|
// 重要:如果用户选择了新的 ChatMode,更新对话模式
|
|
// 这样访客可以在人工客服和 AI 客服之间切换
|
|
if input.ChatMode != "" && input.ChatMode != conv.ChatMode {
|
|
chatMode := input.ChatMode
|
|
updates["chat_mode"] = chatMode
|
|
|
|
// 如果是 AI 模式,验证并更新 AI 配置
|
|
if chatMode == "ai" {
|
|
if input.AIConfigID == nil || *input.AIConfigID == 0 {
|
|
return nil, errors.New("AI 模式需要选择模型配置")
|
|
}
|
|
// 验证配置是否存在且开放
|
|
config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
|
|
if err != nil {
|
|
return nil, errors.New("模型配置不存在")
|
|
}
|
|
if !config.IsPublic {
|
|
return nil, errors.New("该模型未开放给访客使用")
|
|
}
|
|
if !config.IsActive {
|
|
return nil, errors.New("该模型配置已禁用")
|
|
}
|
|
updates["ai_config_id"] = input.AIConfigID
|
|
} else {
|
|
// 切换到人工客服模式,清除 AI 配置
|
|
updates["ai_config_id"] = nil
|
|
}
|
|
}
|
|
|
|
if err := s.conversations.UpdateFields(conv.ID, updates); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 重新获取更新后的对话信息
|
|
conv, err = s.conversations.GetByID(conv.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if isNewConversation {
|
|
now := time.Now()
|
|
chatMode := input.ChatMode
|
|
if chatMode == "" {
|
|
chatMode = "human" // 默认人工模式
|
|
}
|
|
message := &models.Message{
|
|
ConversationID: conv.ID,
|
|
SenderID: 0,
|
|
SenderIsAgent: false,
|
|
Content: "Visitor opened the page",
|
|
MessageType: "system_message",
|
|
ChatMode: chatMode, // 记录系统消息发送时的对话模式
|
|
IsRead: true,
|
|
ReadAt: &now,
|
|
}
|
|
if input.Website != "" {
|
|
message.Content += " [" + input.Website + "]"
|
|
}
|
|
if err := s.messages.Create(message); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if input.Referrer != "" {
|
|
readTime := time.Now()
|
|
chatMode := input.ChatMode
|
|
if chatMode == "" {
|
|
chatMode = "human" // 默认人工模式
|
|
}
|
|
referrerMsg := &models.Message{
|
|
ConversationID: conv.ID,
|
|
SenderID: 0,
|
|
SenderIsAgent: false,
|
|
Content: "Visitor came from [" + input.Referrer + "]",
|
|
MessageType: "system_message",
|
|
ChatMode: chatMode, // 记录系统消息发送时的对话模式
|
|
IsRead: true,
|
|
ReadAt: &readTime,
|
|
}
|
|
if err := s.messages.Create(referrerMsg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
return &InitConversationResult{
|
|
ConversationID: conv.ID,
|
|
Status: conv.Status,
|
|
}, nil
|
|
}
|
|
|
|
// UpdateConversationContact 更新访客的联系信息(邮箱、电话、备注)。
|
|
func (s *ConversationService) UpdateConversationContact(input UpdateConversationContactInput) (*ConversationDetail, error) {
|
|
if _, err := s.conversations.GetByID(input.ConversationID); err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrConversationNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
updates := map[string]interface{}{}
|
|
|
|
if input.Email != nil {
|
|
updates["email"] = strings.TrimSpace(*input.Email)
|
|
}
|
|
if input.Phone != nil {
|
|
updates["phone"] = strings.TrimSpace(*input.Phone)
|
|
}
|
|
if input.Notes != nil {
|
|
updates["notes"] = strings.TrimSpace(*input.Notes)
|
|
}
|
|
|
|
if err := s.conversations.UpdateFields(input.ConversationID, updates); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// UpdateConversationContact 不传递 userID,因为更新联系信息时不需要检查参与状态
|
|
return s.GetConversationDetail(input.ConversationID, 0)
|
|
}
|
|
|
|
func (s *ConversationService) buildSummary(conv models.Conversation, userID uint) (ConversationSummary, error) {
|
|
var lastSeen *time.Time
|
|
if conv.LastSeenAt != nil {
|
|
lastSeen = conv.LastSeenAt
|
|
}
|
|
|
|
// 检查当前用户是否参与过该会话(是否发送过消息)
|
|
hasParticipated := false
|
|
if userID > 0 {
|
|
if participated, err := s.messages.HasAgentParticipated(conv.ID, userID); err == nil {
|
|
hasParticipated = participated
|
|
}
|
|
// 错误时静默处理,不影响流程
|
|
}
|
|
|
|
summary := ConversationSummary{
|
|
ID: conv.ID,
|
|
ConversationType: conv.ConversationType,
|
|
VisitorID: conv.VisitorID,
|
|
AgentID: conv.AgentID,
|
|
Status: conv.Status,
|
|
ChatMode: conv.ChatMode,
|
|
CreatedAt: conv.CreatedAt,
|
|
UpdatedAt: conv.UpdatedAt,
|
|
LastSeenAt: lastSeen,
|
|
HasParticipated: hasParticipated,
|
|
}
|
|
|
|
if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil {
|
|
var readAt *time.Time
|
|
if message.ReadAt != nil {
|
|
readAt = message.ReadAt
|
|
}
|
|
summary.LastMessage = &LastMessageSummary{
|
|
ID: message.ID,
|
|
Content: message.Content,
|
|
SenderIsAgent: message.SenderIsAgent,
|
|
MessageType: message.MessageType,
|
|
IsRead: message.IsRead,
|
|
ReadAt: readAt,
|
|
CreatedAt: message.CreatedAt,
|
|
}
|
|
}
|
|
|
|
if count, err := s.messages.CountUnreadBySender(conv.ID, false); err == nil {
|
|
summary.UnreadCount = count
|
|
}
|
|
|
|
return summary, nil
|
|
}
|
|
|
|
// ListConversations 返回当前活跃会话的摘要信息。
|
|
// userID: 当前登录的客服ID(可选,如果为0则使用默认过滤规则)
|
|
// 过滤规则:
|
|
// 1. 默认不显示 ChatMode == "ai" 的对话
|
|
// 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
|
|
// 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
|
|
func (s *ConversationService) ListConversations(userID uint) ([]ConversationSummary, error) {
|
|
conversations, err := s.conversations.ListActive()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]ConversationSummary, 0, len(conversations))
|
|
for _, conv := range conversations {
|
|
// 过滤规则 1: 默认不显示 AI 对话
|
|
// 只有在会话页面手动开启"显示 AI 对话"时才显示
|
|
if conv.ChatMode == "ai" {
|
|
continue
|
|
}
|
|
|
|
// 过滤规则 2: 如果是人工对话,检查是否有访客发送的消息
|
|
// 只有当访客切换到人工并发送消息后,才显示在列表中
|
|
if conv.ChatMode == "human" {
|
|
hasVisitorMessage, err := s.messages.HasVisitorMessageInHumanMode(conv.ID)
|
|
if err != nil {
|
|
// 如果查询失败,为了安全起见,不显示该对话
|
|
continue
|
|
}
|
|
if !hasVisitorMessage {
|
|
// 没有访客消息,不显示(访客只是切换了模式,但还没发送消息)
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 通过过滤,添加到结果列表
|
|
summary, err := s.buildSummary(conv, userID)
|
|
if err != nil {
|
|
continue // 如果构建摘要失败,跳过该对话
|
|
}
|
|
result = append(result, summary)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetConversationDetail 获取指定会话的详细信息。内部对话仅创建者(agent_id)可查看。
|
|
func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*ConversationDetail, error) {
|
|
conv, err := s.conversations.GetByID(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if conv.ConversationType == "internal" && userID > 0 && conv.AgentID != userID {
|
|
return nil, gorm.ErrRecordNotFound
|
|
}
|
|
|
|
summary, err := s.buildSummary(*conv, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var lastSeen *time.Time
|
|
if conv.LastSeenAt != nil {
|
|
lastSeen = conv.LastSeenAt
|
|
}
|
|
|
|
return &ConversationDetail{
|
|
ConversationSummary: summary,
|
|
Website: conv.Website,
|
|
Referrer: conv.Referrer,
|
|
Browser: conv.Browser,
|
|
OS: conv.OS,
|
|
Language: conv.Language,
|
|
IPAddress: conv.IPAddress,
|
|
Location: conv.Location,
|
|
Email: conv.Email,
|
|
Phone: conv.Phone,
|
|
Notes: conv.Notes,
|
|
LastSeen: lastSeen,
|
|
}, nil
|
|
}
|
|
|
|
// SearchConversations 根据关键字检索会话摘要。
|
|
// userID: 当前登录的客服ID(可选,用于检查参与状态)
|
|
func (s *ConversationService) SearchConversations(query string, userID uint) ([]ConversationSummary, error) {
|
|
pattern := "%" + query + "%"
|
|
|
|
idSet := map[uint]struct{}{}
|
|
|
|
if ids, err := s.messages.FindConversationIDsByContent(pattern); err == nil {
|
|
for _, id := range ids {
|
|
idSet[id] = struct{}{}
|
|
}
|
|
} else {
|
|
return nil, err
|
|
}
|
|
|
|
if convs, err := s.conversations.SearchByIDOrVisitorLike(pattern); err == nil {
|
|
for _, conv := range convs {
|
|
idSet[conv.ID] = struct{}{}
|
|
}
|
|
} else {
|
|
return nil, err
|
|
}
|
|
|
|
if len(idSet) == 0 {
|
|
return []ConversationSummary{}, nil
|
|
}
|
|
|
|
ids := make([]uint, 0, len(idSet))
|
|
for id := range idSet {
|
|
ids = append(ids, id)
|
|
}
|
|
|
|
conversations, err := s.conversations.ListByIDs(ids)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := make([]ConversationSummary, 0, len(conversations))
|
|
for _, conv := range conversations {
|
|
summary, err := s.buildSummary(conv, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, summary)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// UpdateVisitorOnlineStatus 更新访客在线状态和最后活跃时间。
|
|
// 当 isOnline 为 true 时,更新 last_seen_at 为当前时间,并确保状态为 "open"。
|
|
// 当 isOnline 为 false 时,仅更新 last_seen_at 为当前时间,不改变状态。
|
|
func (s *ConversationService) UpdateVisitorOnlineStatus(conversationID uint, isOnline bool) error {
|
|
now := time.Now()
|
|
updates := map[string]interface{}{
|
|
"last_seen_at": &now,
|
|
}
|
|
|
|
// 如果标记为在线,确保状态为 "open"(但不要将已关闭的会话重新打开)
|
|
if isOnline {
|
|
conv, err := s.conversations.GetByID(conversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// 只有当前状态不是 "closed" 时,才更新为 "open"
|
|
if conv.Status != "closed" {
|
|
updates["status"] = "open"
|
|
}
|
|
}
|
|
|
|
return s.conversations.UpdateFields(conversationID, updates)
|
|
}
|
|
|
|
// UpdateLastSeenAt 更新访客的最后活跃时间。
|
|
func (s *ConversationService) UpdateLastSeenAt(conversationID uint) error {
|
|
now := time.Now()
|
|
return s.conversations.UpdateFields(conversationID, map[string]interface{}{
|
|
"last_seen_at": &now,
|
|
})
|
|
}
|
|
|
|
// InitInternalConversation 为客服创建一条新的内部对话(知识库测试用)。每次调用创建新会话。
|
|
func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConversationResult, error) {
|
|
if agentID == 0 {
|
|
return nil, errors.New("agent_id is required for internal conversation")
|
|
}
|
|
conv := &models.Conversation{
|
|
ConversationType: "internal",
|
|
VisitorID: 0,
|
|
AgentID: agentID,
|
|
Status: "open",
|
|
ChatMode: "ai",
|
|
}
|
|
if err := s.conversations.Create(conv); err != nil {
|
|
return nil, err
|
|
}
|
|
return &InitConversationResult{
|
|
ConversationID: conv.ID,
|
|
Status: conv.Status,
|
|
}, nil
|
|
}
|
|
|
|
// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
|
|
func (s *ConversationService) ListInternalConversations(agentID uint) ([]ConversationSummary, error) {
|
|
if agentID == 0 {
|
|
return []ConversationSummary{}, nil
|
|
}
|
|
conversations, err := s.conversations.ListActiveInternalByAgentID(agentID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make([]ConversationSummary, 0, len(conversations))
|
|
for _, conv := range conversations {
|
|
summary, err := s.buildSummary(conv, agentID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result = append(result, summary)
|
|
}
|
|
return result, nil
|
|
}
|