feat(chat): implement sequence-based unread count tracking
All checks were successful
Build Backend / build (push) Successful in 1m56s
Build Backend / build-docker (push) Successful in 1m9s

Introduce a new mechanism for tracking read positions using message
sequences (hasReadSeq), similar to OpenIM, to allow for O(1) unread
count calculations.

- Implement `UserReadSeq` caching in Redis with Lua scripts to prevent
  sequence regression (ensuring updates only occur if the new sequence
  is greater than the current one).
- Update `ConversationCache` to support sequence-based unread count
  computation (`maxSeq - hasReadSeq`).
- Refactor `MessageRepository` to use conditional updates for
  `last_read_seq` in the database.
- Update `ChatService` to automatically update the sender's read
  sequence when sending a message.
- Optimize `MarkAsRead` logic to update both database and Redis
  sequence caches and refine notification targets for private vs
  group chats.
- Update `jpush` client to use pointer types for boolean fields to
  properly handle optional values in JSON.
This commit is contained in:
2026-05-10 13:36:58 +08:00
parent 43348615c0
commit 628a6acbe9
6 changed files with 315 additions and 23 deletions

View File

@@ -60,6 +60,9 @@ type ChatService interface {
// 批量查询(解决 N+1 问题)
GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error)
GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error)
// 已读位置OpenIM 风格缓存)
GetUserReadSeq(ctx context.Context, conversationID string, userID string) (int64, error)
}
// chatServiceImpl 聊天服务实现
@@ -355,6 +358,12 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
return nil, fmt.Errorf("failed to save message: %w", err)
}
// 发送者自动已读:将发送者的 lastReadSeq 更新到消息 seq
_ = s.repo.UpdateLastReadSeq(conversationID, senderID, message.Seq)
if s.conversationCache != nil {
_ = s.conversationCache.SetUserReadSeq(context.Background(), conversationID, senderID, message.Seq)
}
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
if s.conversationCache != nil {
s.conversationCache.InvalidateMessagePages(conversationID)
@@ -404,7 +413,7 @@ func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conv
if p.UserID == senderID {
continue
}
if totalUnread, uErr := s.repo.GetAllUnreadCount(p.UserID); uErr == nil {
if totalUnread, uErr := s.GetAllUnreadCount(ctx, p.UserID); uErr == nil {
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{
"conversation_id": conversationID,
"total_unread": totalUnread,
@@ -590,8 +599,10 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
return fmt.Errorf("failed to update last read seq: %w", err)
}
// 2. DB 写入成功后,清除 Redis 未读数
// 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),
@@ -611,17 +622,23 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
if pErr == nil {
detailType := "private"
groupID := ""
if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
conv, _ := s.getConversation(ctx, conversationID)
if conv != nil && conv.Type == model.ConversationTypeGroup {
detailType = "group"
if conv.GroupID != nil {
groupID = *conv.GroupID
}
}
targetIDs := make([]string, 0, len(participants))
for _, p := range participants {
targetIDs = append(targetIDs, p.UserID)
// 私聊:通知所有参与者(含对方);群聊:只通知自己
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(targetIDs, "message_read", map[string]any{
s.publishToUsers(readTargets, "message_read", map[string]any{
"detail_type": detailType,
"conversation_id": conversationID,
"group_id": groupID,
@@ -629,7 +646,7 @@ func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string,
"seq": seq,
})
}
if totalUnread, uErr := s.repo.GetAllUnreadCount(userID); uErr == nil {
if totalUnread, uErr := s.GetAllUnreadCount(ctx, userID); uErr == nil {
s.publishToUsers([]string{userID}, "conversation_unread", map[string]any{
"conversation_id": conversationID,
"total_unread": totalUnread,
@@ -650,13 +667,15 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
return 0, fmt.Errorf("failed to get participant: %w", err)
}
// 优先从 Redis Hash 读取
// 优先算术计算maxSeq - hasReadSeqOpenIM 风格O(1) 无 DB
if s.conversationCache != nil {
count, err := s.conversationCache.GetUnreadCountFromHash(ctx, userID, conversationID)
if err == 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)
}
@@ -665,8 +684,24 @@ func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID str
// GetAllUnreadCount 获取所有会话的未读消息总数
func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) {
// 优先从 Redis Hash 读取
// 优先算术计算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
}
}
if len(maxSeqs) > 0 {
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
}
@@ -886,6 +921,55 @@ func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conv
// GetUnreadCountBatch 批量获取用户在多个会话中的未读数
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 {
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 {
continue
}
readSeq, hasRead := readSeqs[convID]
if !hasRead {
result[convID] = maxSeq
} else {
unread := maxSeq - readSeq
if unread < 0 {
unread = 0
}
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 {
return result, nil
}
}
}
}
// 降级到 DB
return s.repo.GetUnreadCountBatch(ctx, userID, convIDs)
}
@@ -893,3 +977,18 @@ func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string
func (s *chatServiceImpl) GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error) {
return s.repo.GetLastMessagesBatch(ctx, convIDs)
}
// GetUserReadSeq 获取用户在某会话的已读位置(优先从 Redis 缓存读取)
func (s *chatServiceImpl) GetUserReadSeq(ctx context.Context, conversationID string, userID string) (int64, error) {
if s.conversationCache != nil {
if seq, err := s.conversationCache.GetUserReadSeq(ctx, conversationID, userID); err == nil {
return seq, nil
}
}
// 降级到 DB
participant, err := s.getParticipant(ctx, conversationID, userID)
if err != nil {
return 0, err
}
return participant.LastReadSeq, nil
}