OpenIM 源码深度解析系列(十):单聊(第四阶段)消息回执流程

270 阅读16分钟

单聊(第四阶段)消息回执流程

概述

第四阶段是OpenIM单聊消息系统的回执处理环节,当用户查看消息后,系统需要将已读状态同步到云端,并广播给发送者和用户的其他设备。本阶段涉及已读回执read_seq机制两个核心概念的协同工作。

已读回执与read_seq的核心区别

在深入源码分析前,必须理解两个关键概念:

1. read_seq(已读序列号)
  • 作用:控制会话级别的未读消息数量和红点显示
  • 粒度:会话级别,记录用户在某个会话中已读到的最大消息序列号
  • 存储位置:Redis缓存 + MongoDB持久化
  • 同步范围:同步到用户的所有设备,实现多端一致的未读数控制
2. 已读回执(isRead状态)
  • 作用:控制单条消息的精确已读状态显示
  • 粒度:消息级别,记录每条消息是否被对方阅读
  • 存储位置:消息表的isRead字段 + AttachedInfo的HasReadTime
  • 同步范围:同步到发送者和接收者的所有设备
3. 协同工作机制
graph TD
    A[用户查看消息] --> B[触发markConversationMessageAsRead]
    B --> C{会话类型判断}
    
    C -->|单聊| D[获取未读消息列表]
    D --> E[提取msgIDs和seqs]
    E --> F[更新read_seq到maxSeq]
    F --> G[标记具体消息isRead=true]
    G --> H[发送已读回执通知]
    
    C -->|群聊| I[只更新read_seq]
    I --> J[发送已读位置通知]
    
    H --> K[对方收到回执]
    K --> L[更新发送消息的isRead状态]
    K --> M[显示已读标识]
    
    J --> N[其他设备同步已读位置]
    N --> O[更新未读数显示]

核心流程图

sequenceDiagram
    participant User as 用户设备
    participant Client as 客户端SDK
    participant Server as 消息服务器
    participant Sender as 发送者设备
    participant OtherDevice as 用户其他设备
    participant Redis as Redis缓存
    participant DB as 数据库

    Note over User,DB: 第四阶段:单聊消息回执流程
    
    User->>Client: 查看消息(UI滚动/点击)
    Client->>Client: markConversationMessageAsRead启动
    
    Note over Client: 第一步:客户端预处理
    Client->>Client: 获取会话信息和未读消息列表
    Client->>Client: 筛选需要标记的消息(msgIDs, seqs)
    
    Note over Client,OtherDevice: 第二步:服务端处理+通知广播
    Client->>Server: MarkConversationAsReadReq(maxSeq, seqs)
    Server->>Redis: 更新用户read_seq缓存
    Server->>DB: 标记指定消息isRead状态
    
    par 并行通知
        Server->>Sender: 发送已读回执通知(seqs)
    and
        Server->>OtherDevice: 发送已读位置通知(hasReadSeq)
    end
    
    Server-->>Client: 返回成功响应
    
    Note over Client: 第三步:更新本地数据库
    Client->>Client: MarkConversationMessageAsReadDB
    Client->>Client: 更新本地消息isRead=true
    Client->>Client: 更新会话未读数=0
    Client->>Client: 触发UI更新事件
    
    Note over Sender: 第四步:发送者设备处理回执
    Sender->>Sender: doReadDrawing处理回执
    Sender->>Sender: 更新发送消息的isRead状态
    Sender->>Sender: 显示"已读"标识
    
    Note over OtherDevice: 第五步:其他设备同步未读状态
    OtherDevice->>OtherDevice: doUnreadCount处理
    OtherDevice->>OtherDevice: 同步未读数显示
    OtherDevice->>OtherDevice: 清除红点提示

第一步:客户端已读标记处理

1.1 用户触发已读操作

Android客户端触发点

// 用户在聊天界面查看消息时触发已读标记
OpenIMClient.getInstance().messageManager.markConversationMessageAsRead(conversationID, callBack);

说明

  • 触发时机:用户滚动消息列表、进入聊天界面、消息显示在可视区域
  • 调用频率:通常有防抖机制,避免频繁调用
  • 参数:conversationID(会话ID)

1.2 核心已读标记方法

客户端SDK核心处理逻辑

// markConversationMessageAsRead 标记会话的所有消息为已读
// 这是标记会话消息已读的核心方法,根据会话类型采用不同的处理策略
func (c *Conversation) markConversationMessageAsRead(ctx context.Context, conversationID string) error {
	// ========== 并发安全保护 ==========
	c.conversationSyncMutex.Lock()
	defer c.conversationSyncMutex.Unlock()

	// ========== 获取会话基础信息 ==========
	conversation, err := c.db.GetConversation(ctx, conversationID)
	if err != nil {
		return err
	}

	// ========== 优化:检查未读数量 ==========
	if conversation.UnreadCount == 0 {
		log.ZWarn(ctx, "unread count is 0", nil, "conversationID", conversationID)
		return nil // 没有未读消息,无需处理
	}

	// ========== 获取序列号状态 ==========
	// 获取对端用户发送的最大序列号(非本人发送)
	peerUserMaxSeq, err := c.db.GetConversationPeerNormalMsgSeq(ctx, conversationID)
	if err != nil {
		return err
	}
	// 获取会话中所有消息的最大序列号
	maxSeq, err := c.db.GetConversationNormalMsgSeq(ctx, conversationID)
	if err != nil {
		return err
	}

	// ========== 根据会话类型进行差异化处理 ==========
	switch conversation.ConversationType {
	case constant.SingleChatType:
		// 单聊处理:需要精确标记每条消息的已读状态
		msgs, err := c.db.GetUnreadMessage(ctx, conversationID)
		if err != nil {
			return err
		}
		log.ZDebug(ctx, "get unread message", "msgs", len(msgs))
		
		// 筛选需要标记为已读的消息ID和序列号
		msgIDs, seqs := c.getAsReadMsgMapAndList(ctx, msgs)
		
		if len(seqs) == 0 {
			// 无需标记具体消息,只需要更新已读位置
			if err := c.markConversationAsReadServer(ctx, conversationID, maxSeq, seqs); err != nil {
				return err
			}
		} else {
			// 有具体消息需要标记已读
			log.ZDebug(ctx, "markConversationMessageAsRead", "conversationID", conversationID, 
				"seqs", seqs, "peerUserMaxSeq", peerUserMaxSeq, "maxSeq", maxSeq)
			
			// 先通知服务器更新云端状态
			if err := c.markConversationAsReadServer(ctx, conversationID, maxSeq, seqs); err != nil {
				return err
			}
			// 再更新本地数据库中的消息状态
			_, err = c.db.MarkConversationMessageAsReadDB(ctx, conversationID, msgIDs)
			if err != nil {
				log.ZWarn(ctx, "MarkConversationMessageAsRead err", err, "conversationID", conversationID, "msgIDs", msgIDs)
			}
		}
		
	case constant.ReadGroupChatType, constant.NotificationChatType:
		// 群聊和通知处理:只需要更新个人已读位置
		log.ZDebug(ctx, "markConversationMessageAsRead", "conversationID", conversationID, 
			"peerUserMaxSeq", peerUserMaxSeq, "maxSeq", maxSeq)
		if err := c.markConversationAsReadServer(ctx, conversationID, maxSeq, nil); err != nil {
			return err
		}
	}

	// ========== 更新本地会话状态 ==========
	// 将会话未读数设置为0
	if err := c.db.UpdateColumnsConversation(ctx, conversationID, map[string]interface{}{"unread_count": 0}); err != nil {
		log.ZError(ctx, "UpdateColumnsConversation err", err, "conversationID", conversationID)
	}
	
	// ========== 触发UI更新事件 ==========
	// 判断最新消息是否已读,触发相应的UI更新
	c.unreadChangeTrigger(ctx, conversationID, peerUserMaxSeq == maxSeq)
	return nil
}

1.3 消息筛选逻辑

筛选需要标记已读的消息

// getAsReadMsgMapAndList 筛选需要标记为已读的消息
// 只处理未读且非本人发送的消息,确保不会重复标记或标记自己的消息
func (c *Conversation) getAsReadMsgMapAndList(ctx context.Context,
	msgs []*model_struct.LocalChatLog) (asReadMsgIDs []string, seqs []int64) {
	for _, msg := range msgs {
		// 筛选条件:未读 && 非本人发送 && 有效序列号
		if !msg.IsRead && msg.SendID != c.loginUserID {
			if msg.Seq == 0 {
				log.ZWarn(ctx, "exception seq", errors.New("exception message "), "msg", msg)
			} else {
				asReadMsgIDs = append(asReadMsgIDs, msg.ClientMsgID)
				seqs = append(seqs, msg.Seq)
			}
		} else {
			log.ZWarn(ctx, "msg can't marked as read", nil, "msg", msg)
		}
	}
	return
}

基本处理步骤

  1. 遍历未读消息列表:逐条检查每个消息的状态
  2. 三重筛选条件:未读状态 + 非本人发送 + 序列号有效
  3. 异常处理:序列号为0的消息记录警告但跳过处理
  4. 收集结果:将符合条件的消息ID和序列号分别收集
  5. 返回双重结果:msgIDs用于本地数据库更新,seqs用于服务端通知

1.4 本地数据库更新

更新本地消息已读状态

// MarkConversationMessageAsReadDB 在本地数据库中标记消息为已读
// 更新isRead字段和AttachedInfo中的HasReadTime时间戳
func (d *DataBase) MarkConversationMessageAsReadDB(ctx context.Context, conversationID string, msgIDs []string) (rowsAffected int64, err error) {
	d.mRWMutex.Lock()
	defer d.mRWMutex.Unlock()
	
	// 查询需要更新的消息(排除自己发送的消息)
	var msgs []*model_struct.LocalChatLog
	if err := d.conn.WithContext(ctx).Table(utils.GetConversationTableName(conversationID)).
		Where("client_msg_id in ? AND send_id != ?", msgIDs, d.loginUserID).Find(&msgs).Error; err != nil {
		return 0, errs.WrapMsg(err, "MarkConversationMessageAsReadDB failed")
	}
	
	// 逐条更新消息状态
	for _, msg := range msgs {
		var attachedInfo sdk_struct.AttachedInfoElem
		utils.JsonStringToStruct(msg.AttachedInfo, &attachedInfo)
		
		// 设置已读时间戳
		attachedInfo.HasReadTime = utils.GetCurrentTimestampByMill()
		msg.IsRead = true
		msg.AttachedInfo = utils.StructToJsonString(attachedInfo)
		
		// 更新数据库记录
		if err := d.conn.WithContext(ctx).Table(utils.GetConversationTableName(conversationID)).
			Where("client_msg_id = ?", msg.ClientMsgID).Updates(msg).Error; err != nil {
			log.ZError(ctx, "MarkConversationMessageAsReadDB failed", err, "msg", msg)
		} else {
			rowsAffected++
		}
	}
	return rowsAffected, nil
}

基本处理步骤

  1. 数据库加锁:防止并发修改导致数据不一致
  2. 查询待更新消息:根据消息ID列表查询,排除自己发送的消息
  3. 遍历消息列表:逐条处理每个需要更新的消息
  4. 解析附加信息:将JSON格式的AttachedInfo转换为结构体
  5. 设置已读时间戳:记录具体的已读时间
  6. 更新已读标志:将isRead字段设置为true
  7. 重新序列化:将更新后的AttachedInfo转回JSON格式
  8. 执行数据库更新:通过消息ID更新数据库记录
  9. 统计更新结果:返回成功更新的消息数量

第二步:服务端已读状态处理

2.1 服务端接收已读请求

MarkConversationAsRead服务端处理(单聊专用)

// MarkConversationAsRead 标记整个会话为已读
// 单聊专用处理,确保已读状态的准确同步
func (m *msgServer) MarkConversationAsRead(ctx context.Context, req *msg.MarkConversationAsReadReq) (*msg.MarkConversationAsReadResp, error) {
	// ========== 获取会话信息 ==========
	conversation, err := m.ConversationLocalCache.GetConversation(ctx, req.UserID, req.ConversationID)
	if err != nil {
		return nil, err
	}

	// ========== 获取当前已读序列号 ==========
	hasReadSeq, err := m.MsgDatabase.GetHasReadSeq(ctx, req.UserID, req.ConversationID)
	if err != nil && !errors.Is(err, redis.Nil) {
		return nil, err
	}

	var seqs []int64
	log.ZDebug(ctx, "MarkConversationAsRead", "hasReadSeq", hasReadSeq, "req.HasReadSeq", req.HasReadSeq)

	// ========== 单聊处理:需要标记具体消息的已读状态 ==========
	// 生成从当前已读位置+1到新已读位置的所有序列号
	// 确保连续性,不会遗漏中间的消息
	for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ {
		seqs = append(seqs, i)
	}

	// 合并客户端传入的序列号,处理消息乱序情况
	for _, val := range req.Seqs {
		if !datautil.Contain(val, seqs...) {
			seqs = append(seqs, val)
		}
	}

	// 批量标记消息为已读
	if len(seqs) > 0 {
		log.ZDebug(ctx, "MarkConversationAsRead", "seqs", seqs, "conversationID", req.ConversationID)
		if err = m.MsgDatabase.MarkSingleChatMsgsAsRead(ctx, req.UserID, req.ConversationID, seqs); err != nil {
			return nil, err
		}
	}

	// 更新用户已读序列号到Redis
	if req.HasReadSeq > hasReadSeq {
		err = m.MsgDatabase.SetHasReadSeq(ctx, req.UserID, req.ConversationID, req.HasReadSeq)
		if err != nil {
			return nil, err
		}
		hasReadSeq = req.HasReadSeq
	}

	// 发送单聊已读通知给对方
	m.sendMarkAsReadNotification(ctx, req.ConversationID, conversation.ConversationType, req.UserID,
		m.conversationAndGetRecvID(conversation, req.UserID), seqs, hasReadSeq)

	// ========== 执行单聊已读回调 ==========
	reqCall := &cbapi.CallbackSingleMsgReadReq{
		ConversationID: conversation.ConversationID,
		UserID:         conversation.OwnerUserID,
		Seqs:           req.Seqs,
		ContentType:    conversation.ConversationType,
	}
	m.webhookAfterSingleMsgRead(ctx, &m.config.WebhooksConfig.AfterSingleMsgRead, reqCall)

	return &msg.MarkConversationAsReadResp{}, nil
}

基本处理步骤

  1. 获取会话信息:验证会话存在性和用户权限
  2. 获取当前已读序列号:从Redis缓存中获取用户当前已读位置
  3. 生成序列号范围:从hasReadSeq+1到req.HasReadSeq的连续序列号
  4. 合并客户端序列号:处理客户端传入的特定序列号,防止遗漏
  5. 批量标记消息已读:调用MarkSingleChatMsgsAsRead更新数据库
  6. 更新Redis缓存:将新的已读序列号写入缓存
  7. 发送已读通知:通知对方用户消息已被阅读
  8. 执行业务回调:触发单聊已读的webhook回调

2.2 Redis缓存更新机制

已读序列号缓存策略

// SetUserReadSeq 设置用户在会话中的已读序列号
// 采用缓存优先策略,只有当新序列号大于缓存值时才更新
func (s *seqUserCacheRedis) SetUserReadSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
	// 先从缓存获取当前已读序列号
	dbSeq, err := s.GetUserReadSeq(ctx, conversationID, userID)
	if err != nil {
		return err
	}
	
	// 只有当新序列号大于当前值时才更新缓存
	// 这确保已读位置只能向前推进,不能回退
	if dbSeq < seq {
		if err := s.rocks.RawSet(ctx, s.getSeqUserReadSeqKey(conversationID, userID), 
			strconv.Itoa(int(seq)), s.readExpireTime); err != nil {
			return errs.Wrap(err)
		}
	}
	return nil
}

// SetUserReadSeqToDB 将已读序列号持久化到数据库
// 这个方法通常在批量处理或定期同步时调用
func (s *seqUserCacheRedis) SetUserReadSeqToDB(ctx context.Context, conversationID string, userID string, seq int64) error {
	return s.mgo.SetUserReadSeq(ctx, conversationID, userID, seq)
}

基本处理步骤

  1. 获取当前已读序列号:从缓存中读取当前值
  2. 序列号比较:确保新序列号大于当前值,保证只能向前推进
  3. 更新Redis缓存:将新序列号写入缓存,设置过期时间
  4. 持久化到数据库:通过SetUserReadSeqToDB方法异步持久化

2.3 已读回执通知构造

构造并发送已读回执通知

// sendMarkAsReadNotification 发送已读标记通知
// 通知相关用户消息已被阅读,用于实现已读状态的实时同步
func (m *msgServer) sendMarkAsReadNotification(ctx context.Context, conversationID string, sessionType int32, sendID, recvID string, seqs []int64, hasReadSeq int64) {
	// 构造已读标记通知的消息体
	tips := &sdkws.MarkAsReadTips{
		MarkAsReadUserID: sendID,         // 执行已读操作的用户ID
		ConversationID:   conversationID, // 会话ID
		Seqs:             seqs,           // 被标记为已读的消息序列号列表
		HasReadSeq:       hasReadSeq,     // 用户已读到的最新序列号
	}

	// 使用通知发送器发送已读回执通知
	// 这会通过WebSocket或其他实时通道通知相关用户
	// constant.HasReadReceipt 是已读回执的消息类型
	m.notificationSender.NotificationWithSessionType(ctx, sendID, recvID, constant.HasReadReceipt, sessionType, tips)
}

基本处理步骤

  1. 构造通知消息体:创建MarkAsReadTips结构体
  2. 设置通知参数:包含用户ID、会话ID、序列号列表、已读位置
  3. 选择通知类型:使用HasReadReceipt类型标识已读回执
  4. 发送实时通知:通过WebSocket等方式实时推送给目标用户

2.4 seqs参数的精确控制机制

关于seqs参数的设计理念

虽然每次调用都会将整个会话标记为已读(通过hasReadSeq),但seqs参数的存在有重要意义:

  1. 网络优化:seqs只包含实际需要处理的消息序列号,减少网络传输
  2. 精确控制:在消息乱序或部分同步场景下,确保特定消息被正确标记
  3. 回执通知:seqs用于通知发送者具体哪些消息被阅读了
  4. 增量处理:避免重复处理已经标记过的消息

实际处理逻辑

// 服务端会生成完整的序列号范围
for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ {
    seqs = append(seqs, i)
}

// 同时合并客户端传入的特定序列号
for _, val := range req.Seqs {
    if !datautil.Contain(val, seqs...) {
        seqs = append(seqs, val)
    }
}

这种设计确保了:

  • 完整性:不会遗漏任何应该标记的消息
  • 精确性:客户端可以指定特殊需要处理的消息
  • 效率性:避免重复处理和不必要的网络传输

第三步:已读状态广播与同步

3.1 消息同步器接收处理

设备端接收已读回执通知

// doPushMsg 处理推送消息的核心方法
// 当收到已读回执通知时,会通过triggerNotification进行处理
func (m *MsgSyncer) doPushMsg(ctx context.Context, push *sdkws.PushMessages) {
	// 按会话ID分组推送消息
	pushMessages := make(map[string]*sdkws.PullMsgs)
	for conversationID, msgs := range push.Msgs {
		pushMessages[conversationID] = msgs
	}
	
	// 根据消息类型选择不同的触发函数
	for conversationID := range pushMessages {
		if IsNotification(conversationID) {
			// 通知类消息(包括已读回执)使用通知触发器
			if err := m.pushTriggerAndSync(ctx, pushMessages, m.triggerNotification); err != nil {
				log.ZError(ctx, "trigger notification failed", err, "conversationID", conversationID)
			}
		} else {
			// 普通消息使用会话触发器
			if err := m.pushTriggerAndSync(ctx, pushMessages, m.triggerConversation); err != nil {
				log.ZError(ctx, "trigger conversation failed", err, "conversationID", conversationID)
			}
		}
	}
}

// triggerNotification 专门处理已读回执通知
func (m *MsgSyncer) triggerNotification(ctx context.Context, msgs map[string]*sdkws.PullMsgs) error {
	// 遍历所有通知消息
	for conversationID, pullMsgs := range msgs {
		for _, msg := range pullMsgs.Msgs {
			// 处理已读回执消息
			if msg.ContentType == constant.HasReadReceipt {
				// 已读回执消息,分发到会话事件队列处理
				if err := common.DispatchNotificationEvent(ctx, common.NotificationCmd{
					Cmd:   constant.CmdNotification,
					Value: msg,
				}, m.conversationEventQueue); err != nil {
					log.ZError(ctx, "dispatch notification event failed", err, "msg", msg)
					return err
				}
			}
		}
	}
	return nil
}

基本处理步骤

  1. 消息分组:按会话ID将推送消息进行分组
  2. 类型判断:区分通知类消息和普通消息
  3. 触发器选择:已读回执使用通知触发器处理
  4. 事件分发:将已读回执分发到会话事件队列
  5. 异步处理:通过事件队列实现异步处理机制

3.2 通知事件分发处理

会话协程处理已读回执

// doNotificationManager 通知管理器,专门处理已读回执
func (c *Conversation) doNotificationManager(c2v common.Cmd2Value) {
	// 解析通知命令
	cmd, ok := c2v.Value.(common.NotificationCmd)
	if !ok {
		log.ZError(c2v.Ctx, "cmd conversion failed", nil, "cmd", c2v.Value)
		return
	}
	
	// 根据通知类型进行处理
	switch cmd.Cmd {
	case constant.CmdNotification:
		// 处理通知消息
		c.DoNotification(c2v.Ctx, cmd.Value.(*sdkws.MsgData))
	case constant.CmdUpdateConversation:
		// 处理会话更新
		c.doUpdateConversation(c2v)
	case constant.CmdUpdateMessage:
		// 处理消息更新
		c.doUpdateMessage(c2v)
	default:
		log.ZWarn(c2v.Ctx, "unknown notification cmd", nil, "cmd", cmd.Cmd)
	}
}

// DoNotification 专门处理已读回执通知
func (c *Conversation) DoNotification(ctx context.Context, msg *sdkws.MsgData) {
	// 处理已读回执
	if msg.ContentType == constant.HasReadReceipt {
		if err := c.doReadDrawing(ctx, msg); err != nil {
			log.ZError(ctx, "doReadDrawing failed", err, "msg", msg)
		}
	} else {
		log.ZWarn(ctx, "unknown notification content type", nil, "contentType", msg.ContentType)
	}
}

基本处理步骤

  1. 解析通知命令:将事件数据转换为通知命令结构
  2. 命令类型判断:区分不同类型的通知命令
  3. 通知消息处理:对于CmdNotification调用DoNotification方法
  4. 内容类型检查:确认是已读回执类型的通知
  5. 调用处理方法:执行doReadDrawing进行具体的已读回执处理

3.3 已读回执具体处理逻辑

doReadDrawing方法详细实现(单聊专用)

// doReadDrawing 处理已读回执的核心方法
// 根据回执来源(自己/对方)进行不同的处理逻辑
func (c *Conversation) doReadDrawing(ctx context.Context, msg *sdkws.MsgData) error {
	// ========== 解析已读回执内容 ==========
	tips := &sdkws.MarkAsReadTips{}
	err := utils.UnmarshalNotificationElem(msg.Content, tips)
	if err != nil {
		log.ZWarn(ctx, "UnmarshalNotificationElem err", err, "msg", msg)
		return err
	}
	log.ZDebug(ctx, "do readDrawing", "tips", tips)
	
	// ========== 获取会话信息 ==========
	conversation, err := c.db.GetConversation(ctx, tips.ConversationID)
	if err != nil {
		log.ZWarn(ctx, "GetConversation err", err, "conversationID", tips.ConversationID)
		return err
	}
	
	// ========== 根据回执来源进行不同处理 ==========
	if tips.MarkAsReadUserID != c.loginUserID {
		// 对方的已读回执:更新自己发送的消息状态
		if len(tips.Seqs) == 0 {
			return errs.New("tips Seqs is empty").Wrap()
		}
		
		// 获取被标记已读的消息列表
		messages, err := c.db.GetMessagesBySeqs(ctx, tips.ConversationID, tips.Seqs)
		if err != nil {
			log.ZWarn(ctx, "GetMessagesBySeqs err", err, "conversationID", tips.ConversationID, "seqs", tips.Seqs)
			return err
		}
		
		// 单聊处理:更新消息已读状态并触发UI回调
		latestMsg := &sdk_struct.MsgStruct{}
		if err := json.Unmarshal([]byte(conversation.LatestMsg), latestMsg); err != nil {
			log.ZWarn(ctx, "Unmarshal err", err, "conversationID", tips.ConversationID, "latestMsg", conversation.LatestMsg)
			return err
		}
		
		var successMsgIDs []string
		for _, message := range messages {
			// 更新消息的已读时间戳
			attachInfo := sdk_struct.AttachedInfoElem{}
			_ = utils.JsonStringToStruct(message.AttachedInfo, &attachInfo)
			attachInfo.HasReadTime = msg.SendTime  // 使用回执消息的发送时间作为已读时间
			message.AttachedInfo = utils.StructToJsonString(attachInfo)
			message.IsRead = true
			
			// 更新本地数据库
			if err = c.db.UpdateMessage(ctx, tips.ConversationID, message); err != nil {
				log.ZWarn(ctx, "UpdateMessage err", err, "conversationID", tips.ConversationID, "message", message)
				return err
			} else {
				// 如果是最新消息,更新会话的最新消息状态
				if latestMsg.ClientMsgID == message.ClientMsgID {
					latestMsg.IsRead = message.IsRead
					conversation.LatestMsg = utils.StructToJsonString(latestMsg)
					_ = common.DispatchUpdateConversation(ctx, common.UpdateConNode{
						ConID: conversation.ConversationID, 
						Action: constant.AddConOrUpLatMsg, 
						Args: *conversation,
					}, c.ConversationEventQueue())
				}
				successMsgIDs = append(successMsgIDs, message.ClientMsgID)
			}
		}
		
		// 触发已读回执回调,通知应用层
		var messageReceiptResp = []*sdk_struct.MessageReceipt{{
			UserID:      tips.MarkAsReadUserID,
			MsgIDList:   successMsgIDs,
			SessionType: conversation.ConversationType,
			ReadTime:    msg.SendTime,
		}}
		c.msgListener().OnRecvC2CReadReceipt(utils.StructToJsonString(messageReceiptResp))
	} else {
		// 自己的已读回执:更新未读数和会话状态
		return c.doUnreadCount(ctx, conversation, tips.HasReadSeq, tips.Seqs)
	}
	return nil
}

基本处理步骤

  1. 解析回执内容:将消息内容解析为MarkAsReadTips结构体
  2. 获取会话信息:查询相关会话的基本信息
  3. 判断回执来源:区分是对方的回执还是自己其他设备的回执
  4. 处理对方回执
    • 验证序列号列表不为空
    • 获取被标记已读的消息列表
    • 更新消息的已读时间戳和状态
    • 如果是最新消息,同步更新会话状态
    • 触发已读回执回调通知应用层
  5. 处理自己回执:调用doUnreadCount更新未读数

3.4 未读数更新处理

doUnreadCount方法实现(单聊专用)

// doUnreadCount 处理未读数更新逻辑
// 当收到自己其他设备的已读回执时,同步更新本设备的未读状态
func (c *Conversation) doUnreadCount(ctx context.Context, conversation *model_struct.LocalConversation, hasReadSeq int64, seqs []int64) error {
	// 单聊处理:需要更新具体消息的已读状态
	if len(seqs) != 0 {
		// 检查指定序列号的消息是否已读
		hasReadMessage, err := c.db.GetMessageBySeq(ctx, conversation.ConversationID, hasReadSeq)
		if err != nil {
			return err
		}
		
		if hasReadMessage.IsRead {
			// 消息已读,忽略重复的已读信息
			return errs.New("read info from self can be ignored").Wrap()
		} else {
			// 批量标记消息为已读
			_, err := c.db.MarkConversationMessageAsReadBySeqs(ctx, conversation.ConversationID, seqs)
			if err != nil {
				return err
			}
		}
	} else {
		return errs.New("seqList is empty", "conversationID", conversation.ConversationID, "hasReadSeq", hasReadSeq).Wrap()
	}
	
	// 计算新的未读数量
	currentMaxSeq := c.maxSeqRecorder.Get(conversation.ConversationID)
	if currentMaxSeq == 0 {
		return errs.New("currentMaxSeq is 0", "conversationID", conversation.ConversationID).Wrap()
	} else {
		unreadCount := currentMaxSeq - hasReadSeq
		if unreadCount < 0 {
			log.ZWarn(ctx, "unread count is less than 0", nil, "conversationID", conversation.ConversationID, "currentMaxSeq", currentMaxSeq, "hasReadSeq", hasReadSeq)
			unreadCount = 0
		}
		
		// 更新会话未读数
		if err := c.db.UpdateColumnsConversation(ctx, conversation.ConversationID, map[string]interface{}{"unread_count": unreadCount}); err != nil {
			return err
		}
	}
	
	// 检查最新消息是否需要更新已读状态
	latestMsg := &sdk_struct.MsgStruct{}
	if err := json.Unmarshal([]byte(conversation.LatestMsg), latestMsg); err != nil {
		log.ZError(ctx, "Unmarshal err", err, "conversationID", conversation.ConversationID, "latestMsg", conversation.LatestMsg)
		return err
	}
	
	// 如果最新消息在已读序列号列表中,更新其状态
	if (!latestMsg.IsRead) && datautil.Contain(latestMsg.Seq, seqs...) {
		c.doUpdateConversation(common.Cmd2Value{
			Value: common.UpdateConNode{
				ConID: conversation.ConversationID,
				Action: constant.UpdateLatestMessageReadState, 
				Args: []string{conversation.ConversationID},
			}, 
			Ctx: ctx,
		})
	}
	
	// 触发UI更新事件
	c.doUpdateConversation(common.Cmd2Value{
		Value: common.UpdateConNode{
			ConID: conversation.ConversationID, 
			Action: constant.ConChange, 
			Args: []string{conversation.ConversationID},
		},
	})
	c.doUpdateConversation(common.Cmd2Value{
		Value: common.UpdateConNode{Action: constant.TotalUnreadMessageChanged},
	})

	return nil
}

基本处理步骤

  1. 验证序列号列表:确保序列号列表不为空
  2. 检查消息已读状态:获取指定序列号的消息,检查是否已读
  3. 批量标记消息已读:如果消息未读,批量更新为已读状态
  4. 计算新未读数:currentMaxSeq - hasReadSeq
  5. 未读数边界检查:确保未读数不为负数
  6. 更新会话未读数:将计算出的未读数写入数据库
  7. 检查最新消息状态:如果最新消息在已读列表中,更新其状态
  8. 触发UI更新事件:通知界面更新会话状态和总未读数

核心设计特点

1. 双重状态管理

  • read_seq:控制会话级别的未读数和红点显示
  • isRead字段:控制消息级别的精确已读状态

2. 多端一致性保证

  • 通过Redis缓存确保read_seq的实时同步
  • 通过WebSocket推送确保已读状态的实时广播
  • 通过本地数据库确保离线状态下的数据持久化

3. 性能优化策略

  • 批量处理:一次性标记多条消息
  • 缓存优先:Redis缓存减少数据库访问
  • 增量更新:只处理实际需要更新的消息

4. 容错与一致性

  • 序列号连续性检查:防止消息遗漏
  • 重复标记检测:避免重复处理
  • 异常恢复机制:网络异常时的状态恢复

总结

OpenIM的消息回执机制通过read_seq和isRead两套状态系统的协同工作,实现了精确的已读状态控制:

  1. read_seq负责会话级别的未读数管理,确保多端一致的红点显示
  2. isRead状态负责消息级别的精确已读标记,确保发送者能看到准确的已读反馈
  3. seqs参数虽然看似冗余,但在网络优化、精确控制和增量处理方面发挥重要作用
  4. 三协程架构确保了消息处理的高效性和稳定性

这种设计既保证了功能的完整性,又优化了性能和用户体验,是一个成熟的企业级IM系统的已读回执解决方案。