feat(chat): implement batch mark-as-read and message sync data
Refactor unread count logic to use arithmetic calculation (maxSeq - readSeq) instead of manual incrementing/decrementing. This simplifies the cache management and improves consistency. New features: - Batch mark-as-read functionality for multiple conversations. - Lightweight sync data endpoint to retrieve conversation metadata (maxSeq and last message timestamp) for client synchronization. - Optimized batch retrieval of conversation max sequences using Redis MGet. Technical changes: - Deprecated `IncrementUnread` and `ClearUnread` in `ConversationCache`. - Added `GetConvMaxSeqBatch` to reduce network round-trips. - Added `HandleMarkReadAll` and `HandleGetSyncData` handlers. - Updated `ChatService` to support batch operations and sync data retrieval.
This commit is contained in:
@@ -41,9 +41,13 @@ type ChatService interface {
|
||||
|
||||
// 已读管理
|
||||
MarkAsRead(ctx context.Context, conversationID string, userID string, seq int64) error
|
||||
MarkAsReadBatch(ctx context.Context, userID string, items []dto.BatchMarkReadItem) (int, error)
|
||||
GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error)
|
||||
GetAllUnreadCount(ctx context.Context, userID string) (int64, error)
|
||||
|
||||
// 消息同步
|
||||
GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error)
|
||||
|
||||
// 消息扩展功能
|
||||
RecallMessage(ctx context.Context, messageID string, userID string) error
|
||||
DeleteMessage(ctx context.Context, messageID string, userID string) error
|
||||
@@ -388,20 +392,8 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
|
||||
participants, err := s.getParticipants(ctx, conversationID)
|
||||
if err == nil {
|
||||
// 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息
|
||||
// 注意:已移除 IncrementUnread,未读数完全由 maxSeq - readSeq 算术计算
|
||||
if s.conversationCache != nil {
|
||||
for _, p := range participants {
|
||||
if p.UserID == senderID {
|
||||
continue
|
||||
}
|
||||
if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil {
|
||||
zap.L().Warn("increment unread from redis hash failed",
|
||||
zap.String("userID", p.UserID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(incrErr),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(participants)*2)
|
||||
for _, p := range participants {
|
||||
keys = append(keys,
|
||||
@@ -602,15 +594,7 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
|
||||
// 2. 更新 Redis hasReadSeq 缓存(防回退)
|
||||
if s.conversationCache != nil {
|
||||
_ = s.conversationCache.SetUserReadSeq(ctx, conversationID, userID, seq)
|
||||
// 清除旧式 Hash 未读数缓存
|
||||
if clearErr := s.conversationCache.ClearUnread(ctx, userID, conversationID); clearErr != nil {
|
||||
zap.L().Warn("clear unread from redis hash failed",
|
||||
zap.String("userID", userID),
|
||||
zap.String("convID", conversationID),
|
||||
zap.Error(clearErr),
|
||||
)
|
||||
}
|
||||
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
|
||||
// 清除旧// 失效参与者缓存
|
||||
s.conversationCache.InvalidateParticipant(conversationID, userID)
|
||||
// 失效未读数缓存(旧 cache-aside 键)
|
||||
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
|
||||
@@ -656,9 +640,66 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取指定会话的未读消息数(带缓存)
|
||||
// MarkAsReadBatch 批量标记多个会话已读
|
||||
func (s *chatServiceImpl) MarkAsReadBatch(ctx context.Context, userID string, items []dto.BatchMarkReadItem) (int, error) {
|
||||
successCount := 0
|
||||
|
||||
for _, item := range items {
|
||||
_, err := s.getParticipant(ctx, item.ConversationID, userID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateLastReadSeq(item.ConversationID, userID, item.LastReadSeq); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.conversationCache != nil {
|
||||
_ = s.conversationCache.SetUserReadSeq(ctx, item.ConversationID, userID, item.LastReadSeq)
|
||||
s.conversationCache.InvalidateParticipant(item.ConversationID, userID)
|
||||
s.conversationCache.InvalidateUnreadCount(userID, item.ConversationID)
|
||||
}
|
||||
|
||||
successCount++
|
||||
}
|
||||
|
||||
if s.conversationCache != nil {
|
||||
s.conversationCache.InvalidateConversationList(userID)
|
||||
}
|
||||
|
||||
// 为每个成功的会话发送 read 通知
|
||||
for i := 0; i < successCount && i < len(items); i++ {
|
||||
item := items[i]
|
||||
participants, pErr := s.getParticipants(ctx, item.ConversationID)
|
||||
if pErr == nil {
|
||||
conv, _ := s.getConversation(ctx, item.ConversationID)
|
||||
readTargets := []string{userID}
|
||||
if conv != nil && conv.Type == model.ConversationTypePrivate {
|
||||
readTargets = make([]string, 0, len(participants))
|
||||
for _, p := range participants {
|
||||
readTargets = append(readTargets, p.UserID)
|
||||
}
|
||||
}
|
||||
s.publishToUsers(readTargets, "message_read", map[string]any{
|
||||
"conversation_id": item.ConversationID,
|
||||
"user_id": userID,
|
||||
"seq": item.LastReadSeq,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 发送总未读数通知
|
||||
if totalUnread, uErr := s.GetAllUnreadCount(ctx, userID); uErr == nil {
|
||||
s.publishToUsers([]string{userID}, "conversation_unread", map[string]any{
|
||||
"total_unread": totalUnread,
|
||||
})
|
||||
}
|
||||
|
||||
return successCount, nil
|
||||
}
|
||||
|
||||
// GetUnreadCount 获取指定会话的未读消息数(纯算术:maxSeq - readSeq)
|
||||
func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) {
|
||||
// 验证用户是否是会话参与者
|
||||
_, err := s.getParticipant(ctx, conversationID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -667,45 +708,29 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
|
||||
return 0, fmt.Errorf("failed to get participant: %w", err)
|
||||
}
|
||||
|
||||
// 优先算术计算:maxSeq - hasReadSeq(OpenIM 风格,O(1) 无 DB)
|
||||
if s.conversationCache != nil {
|
||||
if count, err := s.conversationCache.ComputeUnreadCount(ctx, conversationID, userID); err == nil {
|
||||
return count, nil
|
||||
}
|
||||
// 降级到 Redis Hash
|
||||
if count, err := s.conversationCache.GetUnreadCountFromHash(ctx, userID, conversationID); err == nil {
|
||||
return count, nil
|
||||
}
|
||||
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
|
||||
}
|
||||
|
||||
return s.repo.GetUnreadCount(conversationID, userID)
|
||||
}
|
||||
|
||||
// GetAllUnreadCount 获取所有会话的未读消息总数
|
||||
// GetAllUnreadCount 获取所有会话的未读消息总数(批量 MGet maxSeq,纯算术)
|
||||
func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) {
|
||||
// 优先算术计算:sum(maxSeq - hasReadSeq)(OpenIM 风格)
|
||||
if s.conversationCache != nil {
|
||||
if convs, _, err := s.GetConversationList(ctx, userID, 1, 200); err == nil && len(convs) > 0 {
|
||||
convIDs := make([]string, len(convs))
|
||||
maxSeqs := make(map[string]int64, len(convs))
|
||||
for i, conv := range convs {
|
||||
convIDs[i] = conv.ID
|
||||
if maxSeq, err := s.conversationCache.GetConvMaxSeq(ctx, conv.ID); err == nil {
|
||||
maxSeqs[conv.ID] = maxSeq
|
||||
}
|
||||
}
|
||||
// 只有全部会话的 msg_seq 都命中时才用算术结果,避免部分缺失导致低估
|
||||
if len(maxSeqs) == len(convIDs) {
|
||||
maxSeqs, err := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs)
|
||||
if err == nil && len(maxSeqs) == len(convIDs) {
|
||||
if total, err := s.conversationCache.ComputeAllUnreadCount(ctx, userID, convIDs, maxSeqs); err == nil {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 降级到 Redis Hash
|
||||
if total, err := s.conversationCache.GetTotalUnread(ctx, userID); err == nil {
|
||||
return total, nil
|
||||
}
|
||||
}
|
||||
return s.repo.GetAllUnreadCount(userID)
|
||||
}
|
||||
@@ -920,23 +945,18 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// GetUnreadCountBatch 批量获取用户在多个会话中的未读数
|
||||
// GetUnreadCountBatch 批量获取用户在多个会话中的未读数(批量 MGet)
|
||||
func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||||
// 优先算术计算:maxSeq - hasReadSeq(OpenIM 风格)
|
||||
if s.conversationCache != nil && len(convIDs) > 0 {
|
||||
maxSeqs := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
if maxSeq, err := s.conversationCache.GetConvMaxSeq(ctx, convID); err == nil {
|
||||
maxSeqs[convID] = maxSeq
|
||||
}
|
||||
}
|
||||
if len(maxSeqs) > 0 {
|
||||
maxSeqs, err := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs)
|
||||
if err == nil && len(maxSeqs) > 0 {
|
||||
readSeqs, err := s.conversationCache.GetUserReadSeqs(ctx, userID, convIDs)
|
||||
if err == nil {
|
||||
result := make(map[string]int64, len(convIDs))
|
||||
for _, convID := range convIDs {
|
||||
maxSeq, ok := maxSeqs[convID]
|
||||
if !ok {
|
||||
result[convID] = 0
|
||||
continue
|
||||
}
|
||||
readSeq, hasRead := readSeqs[convID]
|
||||
@@ -950,27 +970,12 @@ func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string
|
||||
result[convID] = unread
|
||||
}
|
||||
}
|
||||
// 补齐缺失的会话(未命中缓存时归零)
|
||||
for _, convID := range convIDs {
|
||||
if _, exists := result[convID]; !exists {
|
||||
result[convID] = 0
|
||||
}
|
||||
}
|
||||
// 只有全部会话都算出来了才回返回,否则降级
|
||||
allComputed := true
|
||||
for _, convID := range convIDs {
|
||||
if _, exists := maxSeqs[convID]; !exists {
|
||||
allComputed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allComputed {
|
||||
if len(maxSeqs) == len(convIDs) {
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 降级到 DB
|
||||
return s.repo.GetUnreadCountBatch(ctx, userID, convIDs)
|
||||
}
|
||||
|
||||
@@ -993,3 +998,33 @@ func (s *chatServiceImpl) GetUserReadSeq(ctx context.Context, conversationID str
|
||||
}
|
||||
return participant.LastReadSeq, nil
|
||||
}
|
||||
|
||||
// GetSyncData 获取用户所有会话的同步元数据(seq + 时间,轻量级)
|
||||
func (s *chatServiceImpl) GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error) {
|
||||
convs, _, err := s.GetConversationList(ctx, userID, 1, 200)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get conversation list: %w", err)
|
||||
}
|
||||
|
||||
if len(convs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
convIDs := make([]string, len(convs))
|
||||
for i, conv := range convs {
|
||||
convIDs[i] = conv.ID
|
||||
}
|
||||
|
||||
maxSeqs, _ := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs)
|
||||
|
||||
items := make([]dto.SyncDataItem, 0, len(convs))
|
||||
for _, conv := range convs {
|
||||
item := dto.SyncDataItem{
|
||||
ConversationID: conv.ID,
|
||||
MaxSeq: maxSeqs[conv.ID],
|
||||
LastMessageAt: conv.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user