feat(chat): implement batch mark-as-read and message sync data
All checks were successful
Build Backend / build (push) Successful in 2m2s
Build Backend / build-docker (push) Successful in 1m41s

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:
2026-05-12 18:04:59 +08:00
parent 2f2bbc646e
commit 8d7e8c427b
5 changed files with 236 additions and 175 deletions

View File

@@ -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 - hasReadSeqOpenIM 风格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 - hasReadSeqOpenIM 风格)
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
}