Files
ai-agent/internal/services/message_service.go
T
t 18c9354095 refactor: 将客服后端重构为宿主可嵌入模块
- 注入数据库、运行时配置、统一响应、文件存储和平台 AI 能力,补充业务读写工具与客户快捷操作契约。

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

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

- 统一 HTTP、DTO 与 WebSocket 的 snake_case 协议,补齐模块初始化、业务动作和公共载荷等回归测试。
2026-08-28 22:23:13 +08:00

784 lines
31 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 services
import (
"code.tczkiot.com/wlw/ai-agent/internal/models"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/dto"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/enums"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/errorsx"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/openidentity"
"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"
"time"
"code.tczkiot.com/wlw/ai-agent/internal/pkg/httpx/params"
"github.com/mlogclub/simple/common/strs"
"github.com/mlogclub/simple/sqls"
)
var MessageService = newMessageService()
func newMessageService() *messageService {
return &messageService{}
}
type messageService struct {
}
func (s *messageService) Get(id int64) *models.Message {
return repositories.MessageRepository.Get(sqls.DB(), id)
}
func (s *messageService) Take(where ...interface{}) *models.Message {
return repositories.MessageRepository.Take(sqls.DB(), where...)
}
func (s *messageService) Find(cnd *sqls.Cnd) []models.Message {
return repositories.MessageRepository.Find(sqls.DB(), cnd)
}
// FindByConversationIDCursor 按 id 游标分页:cursor=0 取最新 limit 条;cursor>0 取 id<cursor 的更旧消息。
// 返回的 list 已按 id 升序(时间正序)。nextCursor 为下一页请求传入的游标(本批最小 id);hasMore 表示可能还有更旧消息。
func (s *messageService) FindByConversationIDCursor(conversationID int64, cursor int64, limit int, senderType, messageType string) (list []models.Message, nextCursor int64, hasMore bool) {
if limit > 100 {
limit = 100
} else if limit <= 0 {
limit = 20
}
cnd := sqls.NewCnd().Eq("conversation_id", conversationID).Limit(limit).Desc("id")
if cursor > 0 {
cnd.Lt("id", cursor)
}
if strs.IsNotBlank(senderType) {
cnd.Eq("sender_type", senderType)
}
if strs.IsNotBlank(messageType) {
cnd.Eq("message_type", messageType)
}
list = s.Find(cnd)
nextCursor = cursor
hasMore = false
if len(list) > 0 {
nextCursor = list[len(list)-1].ID
hasMore = len(list) == limit
}
slices.Reverse(list)
return list, nextCursor, hasMore
}
func (s *messageService) FindOne(cnd *sqls.Cnd) *models.Message {
return repositories.MessageRepository.FindOne(sqls.DB(), cnd)
}
func (s *messageService) FindPageByParams(params *params.QueryParams) (list []models.Message, paging *sqls.Paging) {
return repositories.MessageRepository.FindPageByParams(sqls.DB(), params)
}
func (s *messageService) FindPageByCnd(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) {
return repositories.MessageRepository.FindPageByCnd(sqls.DB(), cnd)
}
// FindPageByCndForImListAscending 与 FindPageByCnd 相同分页条件,将结果按 seq 升序排列(开放 IM 时间正序展示)。
func (s *messageService) FindPageByCndForImListAscending(cnd *sqls.Cnd) (list []models.Message, paging *sqls.Paging) {
list, paging = s.FindPageByCnd(cnd)
if len(list) <= 1 {
return list, paging
}
for i, j := 0, len(list)-1; i < j; i, j = i+1, j-1 {
list[i], list[j] = list[j], list[i]
}
return list, paging
}
func (s *messageService) Count(cnd *sqls.Cnd) int64 {
return repositories.MessageRepository.Count(sqls.DB(), cnd)
}
func (s *messageService) Create(t *models.Message) error {
return repositories.MessageRepository.Create(sqls.DB(), t)
}
func (s *messageService) Update(t *models.Message) error {
return repositories.MessageRepository.Update(sqls.DB(), t)
}
func (s *messageService) Updates(id int64, columns map[string]interface{}) error {
return repositories.MessageRepository.Updates(sqls.DB(), id, columns)
}
func (s *messageService) UpdateColumn(id int64, name string, value interface{}) error {
return repositories.MessageRepository.UpdateColumn(sqls.DB(), id, name, value)
}
func (s *messageService) Delete(id int64) {
repositories.MessageRepository.Delete(sqls.DB(), id)
}
func (s *messageService) GetConversationReadTarget(conversationID, messageID int64) (*models.Message, error) {
if messageID > 0 {
message := s.Get(messageID)
if message == nil || message.ConversationID != conversationID {
return nil, errorsx.InvalidParamI18n("error.e0244")
}
return message, nil
}
return s.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Desc("id")), nil
}
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, "")
case enums.IMSenderTypeAI:
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, "")
default:
return nil, errorsx.InvalidParamI18n("error.e0080")
}
}
func (s *messageService) SendAgentMessage(conversationID int64, reqSenderID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) {
return s.SendAgentMessageWithRequestID(conversationID, reqSenderID, clientMsgID, messageType, content, payload, operator, "")
}
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)
}
func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthPrincipal) (*models.Message, error) {
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
if messageID <= 0 {
return nil, errorsx.InvalidParamI18n("error.e0244")
}
message := s.Get(messageID)
if message == nil {
return nil, errorsx.InvalidParamI18n("error.e0244")
}
if message.SenderType != enums.IMSenderTypeAgent {
return nil, errorsx.InvalidParamI18n("error.e0091")
}
if message.SenderID != operator.UserID {
return nil, errorsx.ForbiddenI18n("error.e0087")
}
if message.RecalledAt != nil || message.SendStatus == enums.IMMessageStatusRecalled {
return nil, errorsx.InvalidParamI18n("error.e0246")
}
conversation, err := s.ValidateConversationSender(message.ConversationID, enums.IMSenderTypeAgent, operator, nil)
if err != nil {
return nil, err
}
now := time.Now()
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
updates := map[string]any{
"send_status": int(enums.IMMessageStatusRecalled),
"recalled_at": now,
"updated_at": now,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
}
if err := repositories.MessageRepository.Updates(ctx.Tx, message.ID, updates); err != nil {
return err
}
message.SendStatus = enums.IMMessageStatusRecalled
message.RecalledAt = &now
message.UpdatedAt = now
message.UpdateUserID = operator.UserID
message.UpdateUserName = operator.Username
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer)
if err != nil {
return err
}
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
if err != nil {
return err
}
conversationUpdates := map[string]any{
"agent_unread_count": agentUnreadCount,
"customer_unread_count": customerUnreadCount,
"updated_at": now,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
}
if conversation.LastMessageID == message.ID {
lastMessage := repositories.MessageRepository.FindLastUnrecalledByConversationID(ctx.Tx, conversation.ID)
if lastMessage != nil {
conversationUpdates["last_message_id"] = lastMessage.ID
conversationUpdates["last_message_at"] = lastMessage.SentAt
conversationUpdates["last_message_summary"] = limitText(buildMessageSummary(lastMessage.MessageType, lastMessage.Content), 255)
} else {
conversationUpdates["last_message_id"] = 0
conversationUpdates["last_message_at"] = nil
conversationUpdates["last_message_summary"] = ""
}
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil {
return err
}
if err := ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeMessageRecall, enums.IMSenderTypeAgent, operator.UserID, "客服撤回消息", ""); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
if updatedConversation := ConversationService.Get(conversation.ID); updatedConversation != nil {
WsService.PublishMessageRecalled(updatedConversation, message)
WsService.PublishConversationChanged(updatedConversation, enums.IMRealtimeEventConversationUpdated)
}
return message, nil
}
func (s *messageService) SendAIMessage(conversationID int64, aiAgentID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, operator *dto.AuthPrincipal) (*models.Message, error) {
return s.SendAIMessageWithRequestID(conversationID, aiAgentID, clientMsgID, messageType, content, payload, operator, "")
}
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.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) {
return s.SendAIServiceNoticeWithRequestID(conversationID, aiAgentID, content, "")
}
func (s *messageService) SendAIServiceNoticeWithRequestID(conversationID int64, aiAgentID int64, content string, 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, aiAgentID, strs.UUID(), enums.IMMessageTypeText, content, "", &dto.AuthPrincipal{
UserID: 0,
Username: "system",
Nickname: "system",
}, nil, requestID)
}
func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversation *models.Conversation, aiAgent *models.AIAgent, now time.Time) (*models.Message, error) {
if ctx == nil || conversation == nil || aiAgent == nil || strings.TrimSpace(aiAgent.WelcomeMessage) == "" {
return nil, nil
}
content, payload, summary, err := s.normalizeMessageContent(conversation.ID, enums.IMMessageTypeText, aiAgent.WelcomeMessage, "")
if err != nil {
return nil, err
}
if strs.IsBlank(content) && strs.IsBlank(payload) {
return nil, nil
}
operator := &dto.AuthPrincipal{
UserID: 0,
Username: "system",
Nickname: "system",
}
message := &models.Message{
ConversationID: conversation.ID,
ClientMsgID: strs.UUID(),
SenderType: enums.IMSenderTypeAI,
SenderID: aiAgent.ID,
MessageType: enums.IMMessageTypeText,
Content: content,
Payload: payload,
SendStatus: enums.IMMessageStatusSent,
SentAt: &now,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: operator.UserID,
CreateUserName: operator.Username,
UpdatedAt: now,
UpdateUserID: operator.UserID,
UpdateUserName: operator.Username,
},
}
if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil {
return nil, err
}
if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message); err != nil {
return nil, err
}
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer)
if err != nil {
return nil, err
}
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
if err != nil {
return nil, err
}
conversationUpdates := map[string]any{
"last_message_id": message.ID,
"last_message_at": now,
"last_active_at": now,
"last_message_summary": limitText(summary, 255),
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": now,
"agent_unread_count": agentUnreadCount,
"customer_unread_count": customerUnreadCount,
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, conversationUpdates); err != nil {
return nil, err
}
if err := ConversationEventLogService.CreateEvent(ctx,
conversation.ID,
enums.IMEventTypeMessageSend,
enums.IMSenderTypeAI,
0,
enums.GetIMSenderTypeLabel(enums.IMSenderTypeAI)+"发送消息",
"",
); err != nil {
return nil, err
}
conversation.LastMessageID = message.ID
conversation.LastMessageAt = now
conversation.LastActiveAt = now
conversation.LastMessageSummary = limitText(summary, 255)
conversation.AgentUnreadCount = int(agentUnreadCount)
conversation.CustomerUnreadCount = int(customerUnreadCount)
conversation.UpdatedAt = now
conversation.UpdateUserID = operator.UserID
conversation.UpdateUserName = operator.Username
return message, nil
}
func (s *messageService) SendCustomerMessage(conversationID int64, clientMsgID string, messageType enums.IMMessageType, content, payload string, external openidentity.ExternalUser) (*models.Message, error) {
return s.SendCustomerMessageWithRequestID(conversationID, clientMsgID, messageType, content, payload, external, "")
}
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.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) (*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) == "" {
return nil, errorsx.UnauthorizedI18n("error.e0149")
}
} else if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
if strs.IsBlank(string(messageType)) {
messageType = enums.IMMessageTypeText
}
conversation, err := s.ValidateConversationSender(conversationID, senderType, operator, external)
if err != nil {
return nil, err
}
return s.sendValidatedMessage(requestContext, conversation, senderType, reqSenderID, clientMsgID, messageType, content, payload, operator, external, requestID, triggerAIReply)
}
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
content, payload, summary, err = s.normalizeMessageContent(conversation.ID, messageType, content, payload)
if err != nil {
return nil, err
}
if strs.IsBlank(content) && strs.IsBlank(payload) {
return nil, errorsx.InvalidParamI18n("error.e0245")
}
// 防抖,消息存在就不再发送了
if strs.IsNotBlank(clientMsgID) {
if existing := repositories.MessageRepository.GetByClientMsgID(sqls.DB(), conversation.ID, clientMsgID); existing != nil {
return existing, nil
}
}
var (
now = time.Now()
traceID = tracex.NormalizeRequestID(requestID)
auditUserID = int64(0)
auditUserName = ""
)
if operator != nil {
auditUserID = operator.UserID
auditUserName = operator.Username
}
if senderType == enums.IMSenderTypeCustomer && external != nil {
auditUserID = 0
auditUserName = displayExternalName(external)
}
message := &models.Message{
ConversationID: conversation.ID,
RequestID: traceID,
ClientMsgID: clientMsgID,
SenderType: senderType,
SenderID: reqSenderID,
MessageType: messageType,
Content: content,
Payload: payload,
SendStatus: enums.IMMessageStatusSent,
SentAt: &now,
AuditFields: models.AuditFields{
CreatedAt: now,
CreateUserID: auditUserID,
CreateUserName: auditUserName,
UpdatedAt: now,
UpdateUserID: auditUserID,
UpdateUserName: auditUserName,
},
}
switch senderType {
case enums.IMSenderTypeAgent:
if message.SenderID == 0 {
message.SenderID = operator.UserID
}
case enums.IMSenderTypeAI:
if message.SenderID == 0 {
message.SenderID = reqSenderID
}
default:
message.SenderID = 0
}
err = sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.MessageRepository.Create(ctx.Tx, message); err != nil {
return err
}
// 处理已读、维度
agentUnreadCount, customerUnreadCount, err := s.handleReadState(ctx, senderType, conversation, operator, message, external)
if err != nil {
return err
}
conversation.LastMessageID = message.ID
conversation.LastMessageAt = now
conversation.LastActiveAt = now
conversation.LastMessageSummary = limitText(summary, 255)
conversation.UpdateUserID = int64(0)
conversation.UpdateUserName = ""
if operator != nil {
conversation.UpdateUserID = operator.UserID
conversation.UpdateUserName = operator.Username
}
if senderType == enums.IMSenderTypeCustomer && external != nil {
conversation.UpdateUserID = 0
conversation.UpdateUserName = displayExternalName(external)
}
conversation.UpdatedAt = now
conversation.AgentUnreadCount = int(agentUnreadCount)
conversation.CustomerUnreadCount = int(customerUnreadCount)
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{
"last_message_id": conversation.LastMessageID,
"last_message_at": conversation.LastMessageAt,
"last_active_at": conversation.LastActiveAt,
"last_message_summary": conversation.LastMessageSummary,
"update_user_id": conversation.UpdateUserID,
"update_user_name": conversation.UpdateUserName,
"updated_at": conversation.UpdatedAt,
"agent_unread_count": conversation.AgentUnreadCount,
"customer_unread_count": conversation.CustomerUnreadCount,
}); err != nil {
return err
}
// 记录事件日志
if err := ConversationEventLogService.CreateEventWithRequestID(ctx, conversation.ID, traceID, enums.IMEventTypeMessageSend, senderType,
func() int64 {
if operator != nil {
return operator.UserID
}
return 0
}(),
enums.GetIMSenderTypeLabel(senderType)+"发送消息",
"",
); err != nil {
return err
}
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
}
// 处理websocket消息
WsService.PublishMessageCreated(conversation, message)
WsService.PublishConversationChanged(conversation, enums.IMRealtimeEventConversationUpdated)
// 企业微信客服消息入队,异步发送
if enqueueErr := ChannelMessageOutboxService.EnqueueWxWorkKFMessage(conversation, message); enqueueErr != nil {
slog.Error("enqueue wxwork kf outbox failed",
"conversation_id", conversation.ID,
"message_id", message.ID,
"error", enqueueErr,
)
}
// 客户发送消息,触发AI回复
shouldTriggerAIReply := len(triggerAIReply) == 0 || triggerAIReply[0]
if senderType == enums.IMSenderTypeCustomer && shouldTriggerAIReply {
if TriggerAIReplyAsyncHook != nil {
TriggerAIReplyAsyncHook(requestContext, *conversation, *message)
}
}
return message, err
}
// handleReadState 根据发送者类型更新会话已读状态,并返回更新后的客服和客户未读消息数。
func (s *messageService) handleReadState(ctx *sqls.TxContext, senderType enums.IMSenderType, conversation *models.Conversation, operator *dto.AuthPrincipal, message *models.Message, external *openidentity.ExternalUser) (agentUnreadCount int64, customerUnreadCount int64, err error) {
readStateType := senderType
if senderType == enums.IMSenderTypeAI {
readStateType = enums.IMSenderTypeAgent
}
if readStateType == enums.IMSenderTypeAgent {
if _, err := ConversationReadStateService.MarkAgentRead(ctx, conversation, operator, message); err != nil {
return 0, 0, err
}
} else {
if _, err := ConversationReadStateService.MarkCustomerRead(ctx, conversation, external, message); err != nil {
return 0, 0, err
}
}
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
if agentUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer); err != nil {
return 0, 0, err
}
if customerUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI); err != nil {
return 0, 0, err
}
return agentUnreadCount, customerUnreadCount, nil
}
func limitText(value string, maxLen int) string {
if maxLen <= 0 {
return ""
}
value = strings.TrimSpace(value)
runes := []rune(value)
if len(runes) <= maxLen {
return value
}
return string(runes[:maxLen])
}
func buildMessageSummary(messageType enums.IMMessageType, content string) string {
content = strings.TrimSpace(content)
if content != "" {
return content
}
switch messageType {
case enums.IMMessageTypeImage:
return "[图片]"
case enums.IMMessageTypeAttachment:
return "[附件]"
case enums.IMMessageTypeHTML:
return utils.BuildHTMLSummary(content)
case "":
return ""
default:
return "[" + string(messageType) + "]"
}
}
func (s *messageService) normalizeMessageContent(conversationID int64, messageType enums.IMMessageType, content, payload string) (string, string, string, error) {
switch messageType {
case enums.IMMessageTypeHTML:
sanitized := utils.SanitizeMessageHTML(content)
normalized, err := utils.NormalizeMessageHTMLAssets(sanitized)
if err != nil {
return "", "", "", errorsx.InvalidParamI18n("error.e0030")
}
summary := utils.BuildHTMLSummary(normalized)
if summary == "" {
return "", "", "", errorsx.InvalidParamI18n("error.e0245")
}
return normalized, "", summary, nil
case enums.IMMessageTypeImage, enums.IMMessageTypeAttachment:
assetPayload, err := parseIMMessageAssetPayload(payload)
if 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])
}
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(assets[0].Filename)
return content, canonicalPayload, summary + s.suffixFilenameForSummary(assets[0].Filename), nil
default:
content = strings.TrimSpace(content)
if content == "" && strings.TrimSpace(payload) == "" {
return "", "", "", errorsx.InvalidParamI18n("error.e0245")
}
return content, strings.TrimSpace(payload), buildMessageSummary(messageType, content), nil
}
}
func (s *messageService) ValidateConversationSender(conversationID int64, senderType enums.IMSenderType, operator *dto.AuthPrincipal, external *openidentity.ExternalUser) (*models.Conversation, 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")
}
switch senderType {
case enums.IMSenderTypeAgent:
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
if conversation.Status != enums.IMConversationStatusActive || conversation.CurrentAssigneeID == 0 {
return nil, errorsx.InvalidParamI18n("error.e0120")
}
if conversation.CurrentAssigneeID != operator.UserID {
return nil, errorsx.ForbiddenI18n("error.e0191")
}
case enums.IMSenderTypeAI:
if operator == nil {
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
}
if conversation.Status != enums.IMConversationStatusAIServing && !s.allowAIMessageOnPendingHandoff(conversation) {
return nil, errorsx.ForbiddenI18n("error.e0189")
}
if conversation.CurrentAssigneeID != 0 {
return nil, errorsx.ForbiddenI18n("error.e0192")
}
case enums.IMSenderTypeCustomer:
if external == nil || !ConversationService.IsCustomerConversationOwner(conversation, *external) {
return nil, errorsx.ForbiddenI18n("error.e0222")
}
default:
return nil, errorsx.InvalidParamI18n("error.e0080")
}
return conversation, nil
}
func (s *messageService) allowAIMessageOnPendingHandoff(conversation *models.Conversation) bool {
if conversation == nil {
return false
}
return conversation.Status == enums.IMConversationStatusPending &&
conversation.HandoffAt != nil &&
conversation.CurrentAssigneeID == 0
}
func (s *messageService) suffixFilenameForSummary(filename string) string {
filename = strings.TrimSpace(filename)
if filename == "" {
return ""
}
return " " + filename
}
func (s *messageService) readMessageID(state *models.ConversationReadState) int64 {
if state == nil {
return 0
}
return state.LastReadMessageID
}