refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。 - 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。 - 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。 - 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/tracex"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/pkg/utils"
|
||||
"code.tczkiot.com/wlw/ai-agent/internal/repositories"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -132,11 +134,11 @@ func (s *messageService) GetConversationReadTarget(conversationID, messageID int
|
||||
func (s *messageService) SendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Message, error) {
|
||||
switch senderType {
|
||||
case enums.IMSenderTypeAgent:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "")
|
||||
case enums.IMSenderTypeAI:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "", 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, "")
|
||||
case enums.IMSenderTypeCustomer:
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "", 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, external, "")
|
||||
default:
|
||||
return nil, errorsx.InvalidParamI18n("error.e0080")
|
||||
}
|
||||
@@ -147,7 +149,7 @@ func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int6
|
||||
}
|
||||
|
||||
func (s *messageService) SendAgentMessageWithRequestID(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) {
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID, 0)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAgent, reqSenderID, clientMsgID, messageType, content, payload, operator, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) {
|
||||
@@ -250,11 +252,7 @@ func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, cl
|
||||
}
|
||||
|
||||
func (s *messageService) SendAIMessageWithRequestID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string) (*models.Message, error) {
|
||||
return s.SendAIMessageWithRequestIDAndWorkflowRunID(conversationID, aiAgentID, clientMsgID, messageType, content, payload, operator, requestID, 0)
|
||||
}
|
||||
|
||||
func (s *messageService) SendAIMessageWithRequestIDAndWorkflowRunID(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, requestID string, workflowRunID int64) (*models.Message, error) {
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID, workflowRunID)
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeAI, aiAgentID, clientMsgID, messageType, content, payload, operator, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendAIServiceNotice(conversationID int64, aiAgentID int64, content string) (*models.Message, error) {
|
||||
@@ -269,11 +267,11 @@ func (s *messageService) SendAIServiceNoticeWithRequestID(conversationID int64,
|
||||
if conversation.Status == enums.IMConversationStatusClosed {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0119")
|
||||
}
|
||||
return s.sendValidatedMessage(conversation, enums.IMSenderTypeAI, aiAgentID, strs.UUID(), enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
|
||||
return s.sendValidatedMessage(context.Background(), conversation, enums.IMSenderTypeAI, aiAgentID, strs.UUID(), enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}, nil, requestID, 0)
|
||||
}, nil, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) {
|
||||
@@ -372,12 +370,53 @@ func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID s
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.SendCustomerMessageWithContextAndRequestID(context.Background(), conversationID, clientMsgID, messageType, content, payload, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithContextAndRequestID(ctx context.Context, conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
ext := external
|
||||
return s.sendMessage(conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID, 0)
|
||||
return s.sendMessageWithContext(ctx, conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithoutAIReplyWithRequestID(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.SendCustomerMessageWithoutAIReplyWithContextAndRequestID(context.Background(), conversationID, clientMsgID, messageType, content, payload, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) SendCustomerMessageWithoutAIReplyWithContextAndRequestID(ctx context.Context, conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
ext := external
|
||||
return s.sendMessageWithAITrigger(ctx, conversationID, enums.IMSenderTypeCustomer, 0, clientMsgID, messageType, content, payload, nil, &ext, requestID, false)
|
||||
}
|
||||
|
||||
func (s *messageService) SendAutomaticServiceMessageWithRequestID(conversationID int64, clientMsgID, content, requestID string) (*models.Message, error) {
|
||||
conversation := ConversationService.Get(conversationID)
|
||||
if conversation == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0116")
|
||||
}
|
||||
if conversation.Status == enums.IMConversationStatusClosed {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0119")
|
||||
}
|
||||
return s.sendValidatedMessage(context.Background(), conversation, enums.IMSenderTypeAI, 0, clientMsgID, enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: "system",
|
||||
Nickname: "system",
|
||||
}, nil, requestID, false)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) {
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.sendMessageWithContext(context.Background(), conversationID, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessageWithContext(ctx context.Context, conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string) (*models.Message, error) {
|
||||
return s.sendMessageWithAITrigger(ctx, conversationID, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, true)
|
||||
}
|
||||
|
||||
func (s *messageService) sendMessageWithAITrigger(requestContext context.Context, conversationID int64, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, triggerAIReply bool) (*models.Message, error) {
|
||||
if requestContext == nil {
|
||||
requestContext = context.Background()
|
||||
}
|
||||
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
if external == nil || strings.TrimSpace(external.ExternalID) == "" {
|
||||
@@ -394,11 +433,11 @@ func (s *messageService) sendMessage(conversationID int64, senderType enums.IMSe
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.sendValidatedMessage(conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, workflowRunID)
|
||||
return s.sendValidatedMessage(requestContext, conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, triggerAIReply)
|
||||
}
|
||||
|
||||
func (s *messageService) sendValidatedMessage(conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, workflowRunID int64) (*models.Message, error) {
|
||||
func (s *messageService) sendValidatedMessage(requestContext context.Context, conversation *models.Conversation, senderType enums.IMSenderType, reqSenderID int64, clientMsgID string,
|
||||
messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal, external *openidentity.ExternalUser, requestID string, triggerAIReply ...bool) (*models.Message, error) {
|
||||
|
||||
var err error
|
||||
var summary string
|
||||
@@ -434,7 +473,6 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
message := &models.Message{
|
||||
ConversationID: conversation.ID,
|
||||
RequestID: traceID,
|
||||
WorkflowRunID: workflowRunID,
|
||||
ClientMsgID: clientMsgID,
|
||||
SenderType: senderType,
|
||||
SenderID: reqSenderID,
|
||||
@@ -525,6 +563,14 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// The preflight lookup and INSERT are intentionally not one atomic step.
|
||||
// If another sender wins the unique client-message key, treat its committed
|
||||
// message as this idempotent send's successful result.
|
||||
if strs.IsNotBlank(clientMsgID) {
|
||||
if existing := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, clientMsgID); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -542,9 +588,10 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
|
||||
}
|
||||
|
||||
// 客户发送消息,触发AI回复
|
||||
if senderType == enums.IMSenderTypeCustomer {
|
||||
shouldTriggerAIReply := len(triggerAIReply) == 0 || triggerAIReply[0]
|
||||
if senderType == enums.IMSenderTypeCustomer && shouldTriggerAIReply {
|
||||
if TriggerAIReplyAsyncHook != nil {
|
||||
TriggerAIReplyAsyncHook(*conversation, *message)
|
||||
TriggerAIReplyAsyncHook(requestContext, *conversation, *message)
|
||||
}
|
||||
}
|
||||
return message, err
|
||||
@@ -624,20 +671,45 @@ func (s *messageService) normalizeMessageContent(conversationID int64, messageTy
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
asset := AssetService.GetByAssetID(assetPayload.AssetID)
|
||||
if err := validateConversationAsset(asset, conversationID, messageType); err != nil {
|
||||
return "", "", "", err
|
||||
items := assetPayload.items()
|
||||
if messageType == enums.IMMessageTypeAttachment && len(items) != 1 {
|
||||
return "", "", "", errorsx.InvalidParamI18n("error.e0345")
|
||||
}
|
||||
assets := make([]*models.Asset, 0, len(items))
|
||||
for _, item := range items {
|
||||
asset := AssetService.GetByAssetID(item.AssetID)
|
||||
if err := validateConversationAsset(asset, conversationID, messageType); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
assets = append(assets, asset)
|
||||
}
|
||||
var canonicalPayload string
|
||||
if len(assetPayload.Assets) > 0 {
|
||||
canonicalPayload, err = buildIMMessageAssetBatchPayload(assets)
|
||||
} else {
|
||||
canonicalPayload, err = buildIMMessageAssetPayload(assets[0])
|
||||
}
|
||||
canonicalPayload, err := buildIMMessageAssetPayload(asset)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
summary := "[附件]"
|
||||
if messageType == enums.IMMessageTypeImage {
|
||||
summary = "[图片]"
|
||||
if len(assets) > 1 {
|
||||
summary += fmt.Sprintf("×%d", len(assets))
|
||||
}
|
||||
content = utils.SanitizeMessageHTML(content)
|
||||
content, err = utils.NormalizeMessageHTMLAssets(content)
|
||||
if err != nil {
|
||||
return "", "", "", errorsx.InvalidParamI18n("error.e0030")
|
||||
}
|
||||
if text := utils.BuildHTMLSummary(content); text != "" {
|
||||
summary += " " + text
|
||||
}
|
||||
return content, canonicalPayload, summary, nil
|
||||
}
|
||||
content = strings.TrimSpace(asset.Filename)
|
||||
return content, canonicalPayload, summary + s.suffixFilenameForSummary(asset.Filename), nil
|
||||
content = strings.TrimSpace(assets[0].Filename)
|
||||
return content, canonicalPayload, summary + s.suffixFilenameForSummary(assets[0].Filename), nil
|
||||
default:
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" && strings.TrimSpace(payload) == "" {
|
||||
|
||||
Reference in New Issue
Block a user