refactor: 将客服后端重构为宿主可嵌入模块

- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

- 移除模块内重复的组织、客户、工单、标签、技能、旧工作流、MCP 和迁移实现,将身份权限与业务主体交由宿主管理。

- 使用 libSQL 重构向量存储,并完善图片消息、访客身份、排队调度、企业微信和支持聊天页面。

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
This commit is contained in:
t
2026-08-28 22:23:13 +08:00
parent 6845c728f8
commit 18c9354095
377 changed files with 13199 additions and 22881 deletions
+97 -25
View File
@@ -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) == "" {