refactor: remove SeqNo from message and conversation read state models, update related logic

This commit is contained in:
mlogclub
2026-06-24 22:53:17 +08:00
parent 48a3e86877
commit 85cfee1967
14 changed files with 177 additions and 73 deletions
+1 -1
Submodule docs updated: db4fd857c4...c95d51e0b9
@@ -698,7 +698,6 @@ func createWorkflowExecutorCustomerMessage(t *testing.T, db *gorm.DB, conversati
SenderType: enums.IMSenderTypeCustomer, SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText, MessageType: enums.IMMessageTypeText,
Content: content, Content: content,
SeqNo: 1,
SendStatus: enums.IMMessageStatusSent, SendStatus: enums.IMMessageStatusSent,
SentAt: &now, SentAt: &now,
} }
+1 -11
View File
@@ -37,10 +37,8 @@ func BuildConversationWithLocale(item *models.Conversation, locale string) respo
CustomerUnreadCount: item.CustomerUnreadCount, CustomerUnreadCount: item.CustomerUnreadCount,
AgentUnreadCount: item.AgentUnreadCount, AgentUnreadCount: item.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState), CustomerLastReadMessageID: readStateMessageID(customerReadState),
CustomerLastReadSeqNo: readStateSeqNo(customerReadState),
CustomerLastReadAt: readStateAt(customerReadState), CustomerLastReadAt: readStateAt(customerReadState),
AgentLastReadMessageID: readStateMessageID(agentReadState), AgentLastReadMessageID: readStateMessageID(agentReadState),
AgentLastReadSeqNo: readStateSeqNo(agentReadState),
AgentLastReadAt: readStateAt(agentReadState), AgentLastReadAt: readStateAt(agentReadState),
ClosedAt: utils.FormatTimePtr(item.ClosedAt), ClosedAt: utils.FormatTimePtr(item.ClosedAt),
ClosedBy: item.ClosedBy, ClosedBy: item.ClosedBy,
@@ -157,7 +155,6 @@ func BuildMessageWithReadStatesAndLocale(item *models.Message, agentReadState, c
MessageType: item.MessageType, MessageType: item.MessageType,
Content: localizeRenderableMessageContent(locale, content), Content: localizeRenderableMessageContent(locale, content),
Payload: payload, Payload: payload,
SeqNo: item.SeqNo,
SendStatus: item.SendStatus, SendStatus: item.SendStatus,
SentAt: utils.FormatTimePtr(item.SentAt), SentAt: utils.FormatTimePtr(item.SentAt),
DeliveredAt: utils.FormatTimePtr(item.DeliveredAt), DeliveredAt: utils.FormatTimePtr(item.DeliveredAt),
@@ -282,7 +279,7 @@ func collectMessageSenderNameMaps(list []models.Message) (aiNames map[int64]stri
} }
func isMessageRead(item *models.Message, state *models.ConversationReadState) bool { func isMessageRead(item *models.Message, state *models.ConversationReadState) bool {
return item != nil && state != nil && state.LastReadSeqNo >= item.SeqNo return item != nil && state != nil && state.LastReadMessageID >= item.ID
} }
func readMessageAt(item *models.Message, state *models.ConversationReadState) string { func readMessageAt(item *models.Message, state *models.ConversationReadState) string {
@@ -299,13 +296,6 @@ func readStateMessageID(state *models.ConversationReadState) int64 {
return state.LastReadMessageID return state.LastReadMessageID
} }
func readStateSeqNo(state *models.ConversationReadState) int64 {
if state == nil {
return 0
}
return state.LastReadSeqNo
}
func readStateAt(state *models.ConversationReadState) string { func readStateAt(state *models.ConversationReadState) string {
if state == nil { if state == nil {
return "" return ""
@@ -1,6 +1,8 @@
package builders package builders
import ( import (
"encoding/json"
"strings"
"testing" "testing"
"agent-desk/internal/models" "agent-desk/internal/models"
@@ -114,3 +116,21 @@ func TestBuildMessageIncludesWorkflowRunID(t *testing.T) {
t.Fatalf("resp.WorkflowRunID=%d want 9988", resp.WorkflowRunID) t.Fatalf("resp.WorkflowRunID=%d want 9988", resp.WorkflowRunID)
} }
} }
func TestBuildMessageJSONDoesNotExposeSeqNo(t *testing.T) {
resp := BuildMessageWithReadStatesAndLocale(&models.Message{
ID: 1,
ConversationID: 2,
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "hello",
}, nil, nil, nil, nil, nil, i18nx.DefaultLocale)
raw, err := json.Marshal(resp)
if err != nil {
t.Fatalf("marshal message response: %v", err)
}
if strings.Contains(string(raw), "seqNo") {
t.Fatalf("message response should not expose seqNo, got %s", raw)
}
}
+1 -3
View File
@@ -380,7 +380,6 @@ type ConversationReadState struct {
ReaderID int64 `gorm:"type:bigint;not null;default:0;uniqueIndex:uk_conversation_reader"` ReaderID int64 `gorm:"type:bigint;not null;default:0;uniqueIndex:uk_conversation_reader"`
ExternalReaderID string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_conversation_reader"` ExternalReaderID string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_conversation_reader"`
LastReadMessageID int64 `gorm:"type:bigint;not null;default:0;index"` LastReadMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
LastReadSeqNo int64 `gorm:"type:bigint;not null;default:0;index"`
LastReadAt *time.Time `gorm:"type:datetime"` LastReadAt *time.Time `gorm:"type:datetime"`
AuditFields AuditFields
} }
@@ -388,7 +387,7 @@ type ConversationReadState struct {
// Message 会话消息。 // Message 会话消息。
type Message struct { type Message struct {
ID int64 `gorm:"primaryKey;autoIncrement"` ID int64 `gorm:"primaryKey;autoIncrement"`
ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_conversation_seq;uniqueIndex:uk_conversation_client_msg"` ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_conversation_client_msg"`
RequestID string `gorm:"type:varchar(128);not null;default:'';index"` RequestID string `gorm:"type:varchar(128);not null;default:'';index"`
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"` WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
ClientMsgID string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_conversation_client_msg"` ClientMsgID string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_conversation_client_msg"`
@@ -398,7 +397,6 @@ type Message struct {
MessageType enums.IMMessageType `gorm:"type:varchar(30);not null;default:'';index"` MessageType enums.IMMessageType `gorm:"type:varchar(30);not null;default:'';index"`
Content string `gorm:"type:text"` Content string `gorm:"type:text"`
Payload string `gorm:"type:text"` Payload string `gorm:"type:text"`
SeqNo int64 `gorm:"type:bigint;not null;default:0;uniqueIndex:uk_conversation_seq"`
SendStatus enums.IMMessageStatus `gorm:"type:int;not null;default:2;index"` SendStatus enums.IMMessageStatus `gorm:"type:int;not null;default:2;index"`
SentAt *time.Time `gorm:"type:datetime;index"` SentAt *time.Time `gorm:"type:datetime;index"`
DeliveredAt *time.Time `gorm:"type:datetime"` DeliveredAt *time.Time `gorm:"type:datetime"`
@@ -37,10 +37,8 @@ type ConversationResponse struct {
CustomerUnreadCount int `json:"customerUnreadCount"` CustomerUnreadCount int `json:"customerUnreadCount"`
AgentUnreadCount int `json:"agentUnreadCount"` AgentUnreadCount int `json:"agentUnreadCount"`
CustomerLastReadMessageID int64 `json:"customerLastReadMessageId"` CustomerLastReadMessageID int64 `json:"customerLastReadMessageId"`
CustomerLastReadSeqNo int64 `json:"customerLastReadSeqNo"`
CustomerLastReadAt string `json:"customerLastReadAt,omitempty"` CustomerLastReadAt string `json:"customerLastReadAt,omitempty"`
AgentLastReadMessageID int64 `json:"agentLastReadMessageId"` AgentLastReadMessageID int64 `json:"agentLastReadMessageId"`
AgentLastReadSeqNo int64 `json:"agentLastReadSeqNo"`
AgentLastReadAt string `json:"agentLastReadAt,omitempty"` AgentLastReadAt string `json:"agentLastReadAt,omitempty"`
CustomerOnline bool `json:"customerOnline"` CustomerOnline bool `json:"customerOnline"`
ClosedAt string `json:"closedAt,omitempty"` ClosedAt string `json:"closedAt,omitempty"`
@@ -15,7 +15,6 @@ type MessageResponse struct {
MessageType enums.IMMessageType `json:"messageType"` MessageType enums.IMMessageType `json:"messageType"`
Content string `json:"content"` Content string `json:"content"`
Payload string `json:"payload,omitempty"` Payload string `json:"payload,omitempty"`
SeqNo int64 `json:"seqNo"`
SendStatus enums.IMMessageStatus `json:"sendStatus"` SendStatus enums.IMMessageStatus `json:"sendStatus"`
SentAt string `json:"sentAt,omitempty"` SentAt string `json:"sentAt,omitempty"`
DeliveredAt string `json:"deliveredAt,omitempty"` DeliveredAt string `json:"deliveredAt,omitempty"`
@@ -43,7 +43,6 @@ func (r *messageRepository) FindLastUnrecalledByConversationID(db *gorm.DB, conv
ret := &models.Message{} ret := &models.Message{}
if err := db. if err := db.
Where("conversation_id = ? AND recalled_at IS NULL AND send_status <> ?", conversationID, 6). Where("conversation_id = ? AND recalled_at IS NULL AND send_status <> ?", conversationID, 6).
Order("seq_no DESC").
Order("id DESC"). Order("id DESC").
Limit(1). Limit(1).
Take(ret).Error; err != nil { Take(ret).Error; err != nil {
@@ -118,11 +117,3 @@ func (r *messageRepository) Delete(db *gorm.DB, id int64) {
func (r *messageRepository) GetByClientMsgID(db *gorm.DB, conversationID int64, clientMsgID string) *models.Message { func (r *messageRepository) GetByClientMsgID(db *gorm.DB, conversationID int64, clientMsgID string) *models.Message {
return r.FindOne(db, sqls.NewCnd().Where("conversation_id = ? AND client_msg_id = ?", conversationID, clientMsgID)) return r.FindOne(db, sqls.NewCnd().Where("conversation_id = ? AND client_msg_id = ?", conversationID, clientMsgID))
} }
// NextSeqNo
func (r *messageRepository) NextSeqNo(db *gorm.DB, conversationID int64) int64 {
if last := r.FindOne(db, sqls.NewCnd().Where("conversation_id = ?", conversationID).Desc("seq_no")); last != nil {
return last.SeqNo + 1
}
return 1
}
@@ -160,11 +160,11 @@ func (s *conversationReadStateService) pickConversationReadStates(list []models.
item := &list[i] item := &list[i]
switch item.ReaderType { switch item.ReaderType {
case enums.IMSenderTypeAgent: case enums.IMSenderTypeAgent:
if agentState == nil || item.LastReadSeqNo > agentState.LastReadSeqNo { if agentState == nil || item.LastReadMessageID > agentState.LastReadMessageID {
agentState = item agentState = item
} }
case enums.IMSenderTypeCustomer: case enums.IMSenderTypeCustomer:
if customerState == nil || item.LastReadSeqNo > customerState.LastReadSeqNo { if customerState == nil || item.LastReadMessageID > customerState.LastReadMessageID {
customerState = item customerState = item
} }
} }
@@ -214,7 +214,6 @@ func (s *conversationReadStateService) markReadTxWithCursor(ctx *sqls.TxContext,
ReaderID: c.readerID, ReaderID: c.readerID,
ExternalReaderID: c.externalReaderID, ExternalReaderID: c.externalReaderID,
LastReadMessageID: message.ID, LastReadMessageID: message.ID,
LastReadSeqNo: message.SeqNo,
LastReadAt: &now, LastReadAt: &now,
AuditFields: models.AuditFields{ AuditFields: models.AuditFields{
CreatedAt: now, CreatedAt: now,
@@ -231,19 +230,17 @@ func (s *conversationReadStateService) markReadTxWithCursor(ctx *sqls.TxContext,
return item, nil return item, nil
} }
if item.LastReadSeqNo >= message.SeqNo { if item.LastReadMessageID >= message.ID {
return item, nil return item, nil
} }
item.LastReadMessageID = message.ID item.LastReadMessageID = message.ID
item.LastReadSeqNo = message.SeqNo
item.LastReadAt = &now item.LastReadAt = &now
item.UpdatedAt = now item.UpdatedAt = now
item.UpdateUserID = c.auditUserID item.UpdateUserID = c.auditUserID
item.UpdateUserName = c.auditUserName item.UpdateUserName = c.auditUserName
if err := repositories.ConversationReadStateRepository.Updates(ctx.Tx, item.ID, map[string]any{ if err := repositories.ConversationReadStateRepository.Updates(ctx.Tx, item.ID, map[string]any{
"last_read_message_id": item.LastReadMessageID, "last_read_message_id": item.LastReadMessageID,
"last_read_seq_no": item.LastReadSeqNo,
"last_read_at": item.LastReadAt, "last_read_at": item.LastReadAt,
"updated_at": item.UpdatedAt, "updated_at": item.UpdatedAt,
"update_user_id": item.UpdateUserID, "update_user_id": item.UpdateUserID,
@@ -254,7 +251,7 @@ func (s *conversationReadStateService) markReadTxWithCursor(ctx *sqls.TxContext,
return item, nil return item, nil
} }
func (s *conversationReadStateService) CountUnreadMessages(ctx *sqls.TxContext, conversationID, lastReadSeqNo int64, senderTypes ...enums.IMSenderType) (int64, error) { func (s *conversationReadStateService) CountUnreadMessages(ctx *sqls.TxContext, conversationID, lastReadMessageID int64, senderTypes ...enums.IMSenderType) (int64, error) {
normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes)) normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes))
for _, senderType := range senderTypes { for _, senderType := range senderTypes {
if strs.IsBlank(string(senderType)) { if strs.IsBlank(string(senderType)) {
@@ -267,7 +264,7 @@ func (s *conversationReadStateService) CountUnreadMessages(ctx *sqls.TxContext,
} }
var count int64 var count int64
query := ctx.Tx.Model(&models.Message{}). query := ctx.Tx.Model(&models.Message{}).
Where("conversation_id = ? AND seq_no > ? AND recalled_at IS NULL AND send_status <> ?", conversationID, lastReadSeqNo, int(enums.IMMessageStatusRecalled)) Where("conversation_id = ? AND id > ? AND recalled_at IS NULL AND send_status <> ?", conversationID, lastReadMessageID, int(enums.IMMessageStatusRecalled))
if len(normalizedSenderTypes) == 1 { if len(normalizedSenderTypes) == 1 {
query = query.Where("sender_type = ?", normalizedSenderTypes[0]) query = query.Where("sender_type = ?", normalizedSenderTypes[0])
} else { } else {
+6 -6
View File
@@ -603,7 +603,7 @@ func (s *conversationService) markConversationReadWithActor(conversation *models
} }
currentReadState := actor.getReadState(conversation.ID) currentReadState := actor.getReadState(conversation.ID)
if currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo { if currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
if actor.isAgentSide() && conversation.AgentUnreadCount == 0 { if actor.isAgentSide() && conversation.AgentUnreadCount == 0 {
return false, nil return false, nil
} }
@@ -629,10 +629,10 @@ func (s *conversationService) markConversationReadWithActor(conversation *models
if err != nil { if err != nil {
return err return err
} }
if actor.isAgentSide() && currentConversation.AgentUnreadCount == agentUnreadCount && currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo { if actor.isAgentSide() && currentConversation.AgentUnreadCount == agentUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
return nil return nil
} }
if !actor.isAgentSide() && currentConversation.CustomerUnreadCount == customerUnreadCount && currentReadState != nil && currentReadState.LastReadSeqNo >= targetMessage.SeqNo { if !actor.isAgentSide() && currentConversation.CustomerUnreadCount == customerUnreadCount && currentReadState != nil && currentReadState.LastReadMessageID >= targetMessage.ID {
return nil return nil
} }
updateUserID, updateUserName := actor.conversationUpdateAudit() updateUserID, updateUserName := actor.conversationUpdateAudit()
@@ -651,15 +651,15 @@ func (s *conversationService) markConversationReadWithActor(conversation *models
} }
func (s *conversationService) countUnreadByState(ctx *sqls.TxContext, conversationID int64, state *models.ConversationReadState, senderTypes ...enums.IMSenderType) (int, error) { func (s *conversationService) countUnreadByState(ctx *sqls.TxContext, conversationID int64, state *models.ConversationReadState, senderTypes ...enums.IMSenderType) (int, error) {
lastReadSeqNo := int64(0) lastReadMessageID := int64(0)
if state != nil { if state != nil {
lastReadSeqNo = state.LastReadSeqNo lastReadMessageID = state.LastReadMessageID
} }
normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes)) normalizedSenderTypes := make([]enums.IMSenderType, 0, len(senderTypes))
for _, senderType := range senderTypes { for _, senderType := range senderTypes {
normalizedSenderTypes = append(normalizedSenderTypes, senderType) normalizedSenderTypes = append(normalizedSenderTypes, senderType)
} }
count, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, lastReadSeqNo, normalizedSenderTypes...) count, err := ConversationReadStateService.CountUnreadMessages(ctx, conversationID, lastReadMessageID, normalizedSenderTypes...)
return int(count), err return int(count), err
} }
+9 -12
View File
@@ -126,7 +126,7 @@ func (s *messageService) GetConversationReadTarget(conversationID, messageID int
} }
return message, nil return message, nil
} }
return s.FindOne(sqls.NewCnd().Eq("conversation_id", conversationID).Desc("seq_no").Desc("id")), 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) { 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) {
@@ -197,11 +197,11 @@ func (s *messageService) RecallAgentMessage(messageID int64, operator *dto.AuthP
message.UpdateUserName = operator.Username message.UpdateUserName = operator.Username
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(agentReadState), enums.IMSenderTypeCustomer) agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer)
if err != nil { if err != nil {
return err return err
} }
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI) customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
if err != nil { if err != nil {
return err return err
} }
@@ -302,7 +302,6 @@ func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversatio
MessageType: enums.IMMessageTypeText, MessageType: enums.IMMessageTypeText,
Content: content, Content: content,
Payload: payload, Payload: payload,
SeqNo: repositories.MessageRepository.NextSeqNo(ctx.Tx, conversation.ID),
SendStatus: enums.IMMessageStatusSent, SendStatus: enums.IMMessageStatusSent,
SentAt: &now, SentAt: &now,
AuditFields: models.AuditFields{ AuditFields: models.AuditFields{
@@ -322,11 +321,11 @@ func (s *messageService) createAIWelcomeMessage(ctx *sqls.TxContext, conversatio
return nil, err return nil, err
} }
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(agentReadState), enums.IMSenderTypeCustomer) agentUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer)
if err != nil { if err != nil {
return nil, err return nil, err
} }
customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI) customerUnreadCount, err := ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -423,7 +422,6 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
traceID = tracex.NormalizeRequestID(requestID) traceID = tracex.NormalizeRequestID(requestID)
auditUserID = int64(0) auditUserID = int64(0)
auditUserName = "" auditUserName = ""
nextSeq = repositories.MessageRepository.NextSeqNo(sqls.DB(), conversation.ID)
) )
if operator != nil { if operator != nil {
auditUserID = operator.UserID auditUserID = operator.UserID
@@ -443,7 +441,6 @@ func (s *messageService) sendValidatedMessage(conversation *models.Conversation,
MessageType: messageType, MessageType: messageType,
Content: content, Content: content,
Payload: payload, Payload: payload,
SeqNo: nextSeq,
SendStatus: enums.IMMessageStatusSent, SendStatus: enums.IMMessageStatusSent,
SentAt: &now, SentAt: &now,
AuditFields: models.AuditFields{ AuditFields: models.AuditFields{
@@ -569,10 +566,10 @@ func (s *messageService) handleReadState(ctx *sqls.TxContext, senderType enums.I
} }
} }
agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID) agentReadState, customerReadState := ConversationReadStateService.getConversationReadStates(ctx.Tx, conversation.ID)
if agentUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(agentReadState), enums.IMSenderTypeCustomer); err != nil { if agentUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(agentReadState), enums.IMSenderTypeCustomer); err != nil {
return 0, 0, err return 0, 0, err
} }
if customerUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readSeqNo(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI); err != nil { if customerUnreadCount, err = ConversationReadStateService.CountUnreadMessages(ctx, conversation.ID, s.readMessageID(customerReadState), enums.IMSenderTypeAgent, enums.IMSenderTypeAI); err != nil {
return 0, 0, err return 0, 0, err
} }
return agentUnreadCount, customerUnreadCount, nil return agentUnreadCount, customerUnreadCount, nil
@@ -706,9 +703,9 @@ func (s *messageService) suffixFilenameForSummary(filename string) string {
return " " + filename return " " + filename
} }
func (s *messageService) readSeqNo(state *models.ConversationReadState) int64 { func (s *messageService) readMessageID(state *models.ConversationReadState) int64 {
if state == nil { if state == nil {
return 0 return 0
} }
return state.LastReadSeqNo return state.LastReadMessageID
} }
+130 -3
View File
@@ -1,7 +1,9 @@
package services package services
import ( import (
"fmt"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -163,9 +165,6 @@ func TestConversationCreateCreatesAIWelcomeMessage(t *testing.T) {
if message.Content != "您好,请问有什么可以帮您?" { if message.Content != "您好,请问有什么可以帮您?" {
t.Fatalf("expected trimmed welcome content, got %q", message.Content) t.Fatalf("expected trimmed welcome content, got %q", message.Content)
} }
if message.SeqNo != 1 {
t.Fatalf("expected seq no 1, got %d", message.SeqNo)
}
if message.SendStatus != enums.IMMessageStatusSent { if message.SendStatus != enums.IMMessageStatusSent {
t.Fatalf("expected sent status, got %d", message.SendStatus) t.Fatalf("expected sent status, got %d", message.SendStatus)
} }
@@ -222,6 +221,134 @@ func TestSendCustomerMessageStoresRequestIDOnMessageAndEvent(t *testing.T) {
} }
} }
func TestSendCustomerMessagesConcurrentlyAssignsUniqueIDs(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
external := welcomeTestExternalUser("concurrent-user")
conversation, err := ConversationService.Create(external, 11, aiAgent.ID)
if err != nil {
t.Fatalf("create conversation: %v", err)
}
const messageCount = 10
var wg sync.WaitGroup
errCh := make(chan error, messageCount)
for i := 0; i < messageCount; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
_, err := MessageService.SendCustomerMessageWithRequestID(
conversation.ID,
fmt.Sprintf("client-msg-concurrent-%d", i),
enums.IMMessageTypeText,
"hello concurrent",
"",
external,
"trace-concurrent",
)
errCh <- err
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("SendCustomerMessageWithRequestID() concurrent error = %v", err)
}
}
var messages []models.Message
if err := db.
Where("conversation_id = ? AND sender_type = ?", conversation.ID, enums.IMSenderTypeCustomer).
Order("id ASC").
Find(&messages).Error; err != nil {
t.Fatalf("find messages: %v", err)
}
if len(messages) != messageCount {
t.Fatalf("expected %d customer messages, got %d", messageCount, len(messages))
}
seen := make(map[int64]struct{}, messageCount)
for _, message := range messages {
if message.ID <= 0 {
t.Fatalf("expected persisted message id, got %d", message.ID)
}
if _, ok := seen[message.ID]; ok {
t.Fatalf("duplicate message id %d", message.ID)
}
seen[message.ID] = struct{}{}
}
}
func TestUnreadCountUsesLastReadMessageID(t *testing.T) {
db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "")
conversation := createMessageTestConversation(t, db, aiAgent.ID)
now := time.Now()
messages := []models.Message{
{
ConversationID: conversation.ID,
ClientMsgID: "read-message",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "read",
SendStatus: enums.IMMessageStatusSent,
SentAt: &now,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
},
{
ConversationID: conversation.ID,
ClientMsgID: "unread-message-1",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "unread 1",
SendStatus: enums.IMMessageStatusSent,
SentAt: &now,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
},
{
ConversationID: conversation.ID,
ClientMsgID: "unread-message-2",
SenderType: enums.IMSenderTypeCustomer,
MessageType: enums.IMMessageTypeText,
Content: "unread 2",
SendStatus: enums.IMMessageStatusSent,
SentAt: &now,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
},
}
if err := db.Create(&messages).Error; err != nil {
t.Fatalf("create messages: %v", err)
}
readState := &models.ConversationReadState{
ConversationID: conversation.ID,
ReaderType: enums.IMSenderTypeAgent,
ReaderID: 1,
LastReadMessageID: messages[0].ID,
LastReadAt: &now,
AuditFields: models.AuditFields{CreatedAt: now, UpdatedAt: now},
}
if err := db.Create(readState).Error; err != nil {
t.Fatalf("create read state: %v", err)
}
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
count, err := ConversationService.countUnreadByState(ctx, conversation.ID, readState, enums.IMSenderTypeCustomer)
if err != nil {
return err
}
if count != 2 {
t.Fatalf("unread count=%d want 2", count)
}
return nil
})
if err != nil {
t.Fatalf("count unread: %v", err)
}
}
func TestSendAIMessageStoresWorkflowRunID(t *testing.T) { func TestSendAIMessageStoresWorkflowRunID(t *testing.T) {
db := setupMessageWelcomeTestDB(t) db := setupMessageWelcomeTestDB(t)
aiAgent := createWelcomeTestAIAgent(t, db, "") aiAgent := createWelcomeTestAIAgent(t, db, "")
-3
View File
@@ -146,7 +146,6 @@ type RealtimeMessageCreatedPayload struct {
MessageType enums.IMMessageType `json:"messageType,omitempty"` MessageType enums.IMMessageType `json:"messageType,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Payload string `json:"payload,omitempty"` Payload string `json:"payload,omitempty"`
SeqNo int64 `json:"seqNo,omitempty"`
SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"` SendStatus enums.IMMessageStatus `json:"sendStatus,omitempty"`
SentAt string `json:"sentAt,omitempty"` SentAt string `json:"sentAt,omitempty"`
} }
@@ -201,10 +200,8 @@ type RealtimeConversationChangedPayload struct {
CustomerUnreadCount int `json:"customerUnreadCount,omitempty"` CustomerUnreadCount int `json:"customerUnreadCount,omitempty"`
AgentUnreadCount int `json:"agentUnreadCount,omitempty"` AgentUnreadCount int `json:"agentUnreadCount,omitempty"`
CustomerLastReadMessageID int64 `json:"customerLastReadMessageId,omitempty"` CustomerLastReadMessageID int64 `json:"customerLastReadMessageId,omitempty"`
CustomerLastReadSeqNo int64 `json:"customerLastReadSeqNo,omitempty"`
CustomerLastReadAt string `json:"customerLastReadAt,omitempty"` CustomerLastReadAt string `json:"customerLastReadAt,omitempty"`
AgentLastReadMessageID int64 `json:"agentLastReadMessageId,omitempty"` AgentLastReadMessageID int64 `json:"agentLastReadMessageId,omitempty"`
AgentLastReadSeqNo int64 `json:"agentLastReadSeqNo,omitempty"`
AgentLastReadAt string `json:"agentLastReadAt,omitempty"` AgentLastReadAt string `json:"agentLastReadAt,omitempty"`
} }
+4 -13
View File
@@ -253,7 +253,9 @@ func (s *wsService) closeSession(session *ClientSession) {
remaining := s.manager.Unregister(session) remaining := s.manager.Unregister(session)
close(session.Send) close(session.Send)
_ = session.Conn.Close() if session.Conn != nil {
_ = session.Conn.Close()
}
var discUserID int64 var discUserID int64
var discExternalID string var discExternalID string
@@ -309,7 +311,6 @@ func (s *wsService) PublishMessageCreated(conversation *models.Conversation, mes
MessageType: message.MessageType, MessageType: message.MessageType,
Content: content, Content: content,
Payload: payload, Payload: payload,
SeqNo: message.SeqNo,
SendStatus: message.SendStatus, SendStatus: message.SendStatus,
SentAt: formatWsTime(message.SentAt), SentAt: formatWsTime(message.SentAt),
}, },
@@ -334,7 +335,6 @@ func (s *wsService) buildRealtimeMessage(item *models.Message) response.MessageR
MessageType: item.MessageType, MessageType: item.MessageType,
Content: content, Content: content,
Payload: payload, Payload: payload,
SeqNo: item.SeqNo,
SendStatus: item.SendStatus, SendStatus: item.SendStatus,
SentAt: utils.FormatTimePtr(item.SentAt), SentAt: utils.FormatTimePtr(item.SentAt),
DeliveredAt: utils.FormatTimePtr(item.DeliveredAt), DeliveredAt: utils.FormatTimePtr(item.DeliveredAt),
@@ -389,7 +389,7 @@ func (s *wsService) fillRealtimeMessageUserName(ret *response.MessageResponse, u
} }
func isRealtimeMessageRead(item *models.Message, state *models.ConversationReadState) bool { func isRealtimeMessageRead(item *models.Message, state *models.ConversationReadState) bool {
return item != nil && state != nil && state.LastReadSeqNo >= item.SeqNo return item != nil && state != nil && state.LastReadMessageID >= item.ID
} }
func realtimeReadMessageAt(item *models.Message, state *models.ConversationReadState) string { func realtimeReadMessageAt(item *models.Message, state *models.ConversationReadState) string {
@@ -438,10 +438,8 @@ func (s *wsService) PublishConversationChanged(conversation *models.Conversation
CustomerUnreadCount: conversation.CustomerUnreadCount, CustomerUnreadCount: conversation.CustomerUnreadCount,
AgentUnreadCount: conversation.AgentUnreadCount, AgentUnreadCount: conversation.AgentUnreadCount,
CustomerLastReadMessageID: readStateMessageID(customerReadState), CustomerLastReadMessageID: readStateMessageID(customerReadState),
CustomerLastReadSeqNo: readStateSeqNo(customerReadState),
CustomerLastReadAt: readStateAt(customerReadState), CustomerLastReadAt: readStateAt(customerReadState),
AgentLastReadMessageID: readStateMessageID(agentReadState), AgentLastReadMessageID: readStateMessageID(agentReadState),
AgentLastReadSeqNo: readStateSeqNo(agentReadState),
AgentLastReadAt: readStateAt(agentReadState), AgentLastReadAt: readStateAt(agentReadState),
}, },
}) })
@@ -478,13 +476,6 @@ func readStateMessageID(state *models.ConversationReadState) int64 {
return state.LastReadMessageID return state.LastReadMessageID
} }
func readStateSeqNo(state *models.ConversationReadState) int64 {
if state == nil {
return 0
}
return state.LastReadSeqNo
}
func readStateAt(state *models.ConversationReadState) string { func readStateAt(state *models.ConversationReadState) string {
if state == nil { if state == nil {
return "" return ""