refactor: remove SeqNo from message and conversation read state models, update related logic
This commit is contained in:
+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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user