OpenIM 源码深度解析系列(十二):群聊读扩散机制场景解析

351 阅读21分钟

群聊读扩散机制场景解析

📋 概述

本文档深入解析OpenIM群聊读扩散机制中的7个关键复杂场景,包括消息删除、管理员硬删除、会话清理、群组序号控制、新成员历史权限、消息拉取逻辑以及消息撤回处理等。这些机制确保了群聊系统的数据一致性、权限隔离和多设备同步。

🔢 序号控制体系说明

OpenIM采用五个关键序号字段实现精确的消息权限控制和状态管理:

会话级序号控制(seq表)

  • conversationMinSeq(min_seq):会话全局最小可读序号,主要用于管理员硬删除后的边界控制
  • conversationMaxSeq(max_seq):会话全局最大序号,群内每发送一条消息都会使其递增

用户级序号控制(seq_user表)

  • userMinSeq(min_seq):控制用户可读的最小序号值,主要用于新成员历史消息隐藏
  • userMaxSeq(max_seq):控制用户可读的最大序号值,主要用于退群用户的权限隔离
  • userReadSeq(req_seq):用户已读的序号,用于控制未读红点和已读状态

消息级状态控制

  • msgSeq(seq):每个消息的唯一序号,全局递增,用于消息排序和检索
  • msgIsRead(is_read):消息是否已读标识,在单聊场景中用于已读回执

数据表关系说明

  • seq表:存储会话级的序号边界信息
  • seq_user表:存储每个用户在特定会话中的个性化序号控制
  • conversation表:虽然也包含minSeq和maxSeq字段,但主要为兼容老版本,实际权限控制以seq和seq_user表为准

核心技术点

  • 五层序号管理:conversation级2个 + user级3个 + 消息级状态控制
  • 权限隔离:用户级别的消息可见性精确控制
  • 删除策略:逻辑删除vs物理删除的双重机制,支持管理员硬删除释放存储
  • 同步机制:基于del_list和序号的多设备同步
  • 读扩散实现:会话记录分离,消息统一存储

🚀 第一部分:消息删除逻辑解析

1.1 双重删除机制设计

OpenIM采用逻辑删除物理删除并存的双重删除机制。其中物理删除一般是管理员操作,用于清理无用的群消息,释放存储空间,提升系统性能。

逻辑删除(用户级隐藏)
// 文件:open-im-server/pkg/common/storage/controller/msg.go:577
func (db *commonMsgDatabase) DeleteUserMsgsBySeqs(ctx context.Context, userID string, conversationID string, seqs []int64) error {
    for docID, seqs := range db.msgTable.GetDocIDSeqsMap(conversationID, seqs) {
        for _, seq := range seqs {
            // 关键:将用户ID添加到del_list数组,实现逻辑删除
            if _, err := db.msgDocDatabase.PushUnique(ctx, docID, db.msgTable.GetMsgIndex(seq), "del_list", []string{userID}); err != nil {
                return err
            }
        }
    }
    return db.msgCache.DelMessageBySeqs(ctx, conversationID, seqs)
}
物理删除(全局删除)
// 文件:open-im-server/pkg/common/storage/controller/msg.go:566
func (db *commonMsgDatabase) DeleteMsgsPhysicalBySeqs(ctx context.Context, conversationID string, allSeqs []int64) error {
    for docID, seqs := range db.msgTable.GetDocIDSeqsMap(conversationID, allSeqs) {
        var indexes []int
        for _, seq := range seqs {
            indexes = append(indexes, int(db.msgTable.GetMsgIndex(seq)))
        }
        // 关键:彻底删除消息内容,对所有用户生效
        if err := db.msgDocDatabase.DeleteMsgsInOneDocByIndex(ctx, docID, indexes); err != nil {
            return err
        }
    }
    return db.msgCache.DelMessageBySeqs(ctx, conversationID, allSeqs)
}

1.2 删除模式控制机制

// 文件:open-im-server/internal/rpc/msg/delete.go:98
func (m *msgServer) DeleteMsgs(ctx context.Context, req *msg.DeleteMsgsReq) (*msg.DeleteMsgsResp, error) {
    // 解析同步选项,决定删除模式
    isSyncSelf, isSyncOther := m.validateDeleteSyncOpt(req.DeleteSyncOpt)

    if isSyncOther {
        // 物理删除模式:从数据库中彻底删除消息
        if err := m.MsgDatabase.DeleteMsgsPhysicalBySeqs(ctx, req.ConversationID, req.Seqs); err != nil {
            return nil, err
        }
        
        // 发送删除通知给会话中的其他用户
        tips := &sdkws.DeleteMsgsTips{
            UserID:         req.UserID,
            ConversationID: req.ConversationID,
            Seqs:           req.Seqs,
        }
        m.notificationSender.NotificationWithSessionType(ctx, req.UserID,
            m.conversationAndGetRecvID(conv, req.UserID),
            constant.DeleteMsgsNotification, conv.ConversationType, tips)
    } else {
        // 逻辑删除模式:只对指定用户隐藏消息
        if err := m.MsgDatabase.DeleteUserMsgsBySeqs(ctx, req.UserID, req.ConversationID, req.Seqs); err != nil {
            return nil, err
        }
    }
}

1.3 删除标记检测与过滤

// 文件:open-im-server/pkg/common/storage/controller/msg.go:771
func (db *commonMsgDatabase) handlerDeleteAndRevoked(ctx context.Context, userID string, msgs []*model.MsgInfoModel) {
    for i := range msgs {
        msg := msgs[i]
        if msg == nil || msg.Msg == nil {
            continue
        }
        
        // 检查用户是否在删除列表中
        if datautil.Contain(userID, msg.DelList...) {
            msg.Msg.Content = ""  // 清空消息内容
            msg.Msg.Status = constant.MsgDeleted  // 标记为已删除
        }
    }
}

1.4 消息删除架构图

graph TB
    subgraph "消息删除决策"
        A[删除请求] --> B{同步选项判断}
        B --> |isSyncOther=true| C[物理删除模式]
        B --> |isSyncOther=false| D[逻辑删除模式]
    end
    
    subgraph "物理删除流程"
        C --> E[DeleteMsgsPhysicalBySeqs]
        E --> F[MongoDB文档删除]
        F --> G[Redis缓存清理]
        G --> H[全用户删除通知]
    end
    
    subgraph "逻辑删除流程" 
        D --> I[DeleteUserMsgsBySeqs]
        I --> J[del_list数组添加用户ID]
        J --> K[消息内容保留]
        K --> L[单用户同步通知]
    end
    
    subgraph "消息读取过滤"
        M[GetMessageBySeqs] --> N[handlerDeleteAndRevoked]
        N --> O{用户在del_list中?}
        O --> |是| P[返回空内容+已删除状态]
        O --> |否| Q[返回完整消息内容]
    end
    
    style C fill:#ff9999
    style D fill:#99ccff

🗑️ 第二部分:管理员硬删除机制

2.1 DestructMsgs硬删除概述

管理员硬删除(DestructMsgs)是OpenIM提供的物理删除功能,主要用于:

  • 存储优化:删除过期或无用消息,释放MongoDB存储空间
  • 数据清理:满足数据保留策略和合规性要求(如GDPR)
  • 性能提升:减少索引大小,提高查询性能
  • 系统维护:清理测试数据或异常数据

2.2 硬删除的核心实现

// 文件:open-im-server/internal/rpc/msg/clear.go:17
func (m *msgServer) DestructMsgs(ctx context.Context, req *msg.DestructMsgsReq) (*msg.DestructMsgsResp, error) {
    // ========== 第一步:权限验证 ==========
    // 只有系统管理员才能执行硬删除操作,这是重要的安全检查
    if err := authverify.CheckAdmin(ctx, m.config.Share.IMAdminUserID); err != nil {
        return nil, err
    }

    // ========== 第二步:随机查找待删除文档 ==========
    // 查找指定时间戳之前的消息文档,使用随机查找避免每次删除相同文档
    // Limit参数控制每次删除的文档数量,避免一次性删除过多数据影响性能
    docs, err := m.MsgDatabase.GetRandBeforeMsg(ctx, req.Timestamp, int(req.Limit))
    if err != nil {
        return nil, err
    }

    // ========== 第三步:逐个处理每个文档 ==========
    for i, doc := range docs {
        // 从MongoDB中物理删除文档,这个操作是不可逆的
        if err := m.MsgDatabase.DeleteDoc(ctx, doc.DocID); err != nil {
            return nil, err
        }

        log.ZDebug(ctx, "DestructMsgs delete doc", "index", i, "docID", doc.DocID)

        // ========== 第四步:解析文档ID提取会话ID ==========
        // 文档ID格式通常为 "conversationID:xxx"
        index := strings.LastIndex(doc.DocID, ":")
        if index < 0 {
            continue // 文档ID格式不正确,跳过
        }

        // ========== 第五步:计算最大序列号 ==========
        var maxSeqInDoc int64
        for _, model := range doc.Msg {
            if model.Msg == nil {
                continue
            }
            // 记录文档中最大的序列号
            if model.Msg.Seq > maxSeqInDoc {
                maxSeqInDoc = model.Msg.Seq
            }
        }

        if maxSeqInDoc <= 0 {
            continue
        }

        // 提取会话ID
        conversationID := doc.DocID[:index]
        if conversationID == "" {
            continue
        }

        // ========== 第六步:关键操作 - 设置会话最小序号 ==========
        // 设置conversationMinSeq = maxSeqInDoc + 1
        // 这样可以确保所有小于等于maxSeqInDoc的消息都不会被查询到
        // 即使它们在其他文档中仍然存在,也会被逻辑上"删除"
        newMinSeq := maxSeqInDoc + 1
        if err := m.MsgDatabase.SetMinSeq(ctx, conversationID, newMinSeq); err != nil {
            return nil, err
        }

        log.ZDebug(ctx, "DestructMsgs delete doc set min seq", "index", i, "docID", doc.DocID,
            "conversationID", conversationID, "setMinSeq", newMinSeq)
    }

    // 返回实际删除的文档数量
    return &msg.DestructMsgsResp{Count: int32(len(docs))}, nil
}

2.3 SetMinSeq的边界控制机制

// 文件:open-im-server/pkg/common/storage/controller/msg.go:820
func (db *commonMsgDatabase) SetMinSeq(ctx context.Context, conversationID string, seq int64) error {
    // 先获取当前的conversationMinSeq
    dbSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)
    if err != nil {
        if errors.Is(errs.Unwrap(err), redis.Nil) {
            return nil // 会话不存在,忽略
        }
        return err
    }
    
    // 关键:只有新的序号更大时才更新,确保minSeq单调递增
    if dbSeq >= seq {
        return nil // 不允许回退minSeq
    }
    
    // 更新会话的最小序号
    return db.seqConversation.SetMinSeq(ctx, conversationID, seq)
}

2.4 硬删除的序号影响分析

删除前状态
会话: group_123
├── conversationMinSeq: 1
├── conversationMaxSeq: 10000  
├── 文档范围: [1-100], [101-200], ..., [9901-10000]
└── 用户可见范围: 1-10000
执行硬删除:删除时间戳1000之前的文档
// 假设删除了包含序号1-500的5个文档
// 文档ID: group_123:0, group_123:1, group_123:2, group_123:3, group_123:4
// 最大被删除序号: 500

newMinSeq := 500 + 1 = 501
SetMinSeq(ctx, "group_123", 501)
删除后状态
会话: group_123  
├── conversationMinSeq: 501 (关键变化)
├── conversationMaxSeq: 10000
├── 物理存在文档: [501-600], [601-700], ..., [9901-10000]  
├── 逻辑边界: 501-10000
└── 用户可见范围: 501-10000 (序号1-500的消息不可见)

2.5 消息查询时的边界检查

// 文件:open-im-server/pkg/common/storage/controller/msg.go:429
func (db *commonMsgDatabase) GetMsgBySeqsRange(ctx context.Context, userID string, conversationID string, begin, end, num, userMaxSeq int64) (int64, int64, []*sdkws.MsgData, error) {
    // 获取用户最小序号
    userMinSeq, err := db.seqUser.GetUserMinSeq(ctx, conversationID, userID)
    if err != nil && !errors.Is(err, redis.Nil) {
        return 0, 0, nil, err
    }
    
    // 获取会话最小序号(硬删除后会更新)
    conversationMinSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)
    if err != nil {
        return 0, 0, nil, err
    }
    
    // 取更严格的最小序号限制
    effectiveMinSeq := conversationMinSeq
    if userMinSeq > conversationMinSeq {
        effectiveMinSeq = userMinSeq
    }
    
    // 关键:被硬删除的消息即使用户权限允许,也无法查询到
    if effectiveMinSeq > end {
        log.ZWarn(ctx, "minSeq > end after hard delete", errs.New("minSeq>end"), 
            "effectiveMinSeq", effectiveMinSeq, "end", end)
        return 0, 0, nil, nil // 查询范围在被删除区域内
    }
    
    // 继续后续查询逻辑...
}

2.6 硬删除架构流程图

graph TB
    subgraph "管理员硬删除触发"
        A[管理员权限验证] --> B[DestructMsgs请求]
        B --> C[时间戳+数量限制]
    end
    
    subgraph "文档查找与删除"
        C --> D[GetRandBeforeMsg<br/>随机查找待删除文档]
        D --> E[逐个删除文档<br/>MongoDB物理删除]
        E --> F[解析docID<br/>提取conversationID]
    end
    
    subgraph "序号边界更新"
        F --> G[计算文档最大序号<br/>maxSeqInDoc]
        G --> H[SetMinSeq<br/>conversationMinSeq = maxSeqInDoc + 1]
        H --> I[Redis缓存清理]
    end
    
    subgraph "查询边界生效"
        J[用户消息查询] --> K[GetMinSeq获取边界]
        K --> L{seq < conversationMinSeq?}
        L --> |是| M[消息不可见<br/>已被硬删除]
        L --> |否| N[正常查询流程]
    end
    
    I --> J
    
    style A fill:#ff9999
    style E fill:#ffcccc
    style H fill:#ffffcc
    style M fill:#ccccff

2.7 硬删除使用场景

场景删除策略时间窗口影响范围目的
定期清理删除6个月前消息固定时间戳全系统存储优化
合规要求删除用户注销数据指定时间段特定用户法规遵从
测试清理删除测试群消息测试期间测试环境环境清理
异常处理删除异常数据故障时间段受影响会话数据修复

🧹 第三部分:会话消息清理机制

3.1 序号控制清理策略

会话消息清理通过设置conversationMinSeq实现,而非真正删除消息:

// 文件:open-im-server/internal/rpc/msg/delete.go:200
func (m *msgServer) clearConversation(ctx context.Context, conversationIDs []string, userID string, deleteSyncOpt *msg.DeleteSyncOpt) error {
    // 获取每个会话的最大序列号
    maxSeqs, err := m.MsgDatabase.GetMaxSeqs(ctx, existConversationIDs)
    if err != nil {
        return err
    }

    isSyncSelf, isSyncOther := m.validateDeleteSyncOpt(deleteSyncOpt)

    if !isSyncOther {
        // 逻辑清理:设置用户的最小序列号
        setSeqs := m.getMinSeqs(maxSeqs)  // maxSeq + 1
        
        // 为用户设置会话的最小序列号,隐藏历史消息
        if err := m.MsgDatabase.SetUserConversationsMinSeqs(ctx, userID, setSeqs); err != nil {
            return err
        }
    } else {
        // 物理清理:全局设置最小序列号
        if err := m.MsgDatabase.SetMinSeqs(ctx, m.getMinSeqs(maxSeqs)); err != nil {
            return err
        }
    }
}

3.2 conversationMinSeq计算逻辑

// 文件:open-im-server/internal/rpc/msg/delete.go:37
func (m *msgServer) getMinSeqs(maxSeqs map[string]int64) map[string]int64 {
    minSeqs := make(map[string]int64)
    for k, v := range maxSeqs {
        minSeqs[k] = v + 1  // 关键:最小序列号设置为最大序列号+1
    }
    return minSeqs
}

3.3 会话清理类型对比

清理类型作用范围实现方式可恢复性同步范围
逻辑清理单用户设置UserMinSeq可恢复自己的其他设备
物理清理全用户设置conversationMinSeq不可恢复会话所有参与者
本地清理当前设备本地数据库删除不可恢复

3.4 清理操作的序号影响

graph LR
    subgraph "清理前状态"
        A[MinSeq: 1] --> B[MaxSeq: 1000]
        B --> C[可见消息: 1-1000]
    end
    
    subgraph "执行清理"
        D[getMinSeqs] --> E[newMinSeq = MaxSeq + 1]
        E --> F[newMinSeq = 1001]
    end
    
    subgraph "清理后状态"
        G[MinSeq: 1001] --> H[MaxSeq: 1000]
        H --> I[可见消息: 无]
        I --> J[新消息从1001开始]
    end
    
    A --> D
    F --> G

🚪 第四部分:群组退出序号控制

4.1 退群序号设置机制

用户退群时,系统通过SetConversationMaxSeq设置其userMaxSeq为当前群最大序号,确保重新加入时看不到离开期间的消息。

4.1.1 deleteMemberAndSetConversationSeq 核心方法
// 文件:open-im-server/internal/rpc/group/group.go:1663
func (g *groupServer) deleteMemberAndSetConversationSeq(ctx context.Context, groupID string, userIDs []string) error {
    // 第一步:构建群组会话ID
    conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, groupID)

    // 第二步:获取当前群组会话的最大序列号
    maxSeq, err := g.msgClient.GetConversationMaxSeq(ctx, conversationID)
    if err != nil {
        return err
    }

    // 第三步:关键操作 - 为指定用户设置会话序列号为当前最大值
    // 这样用户重新加入时不会看到离开期间的历史消息
    return g.conversationClient.SetConversationMaxSeq(ctx, conversationID, userIDs, maxSeq)
}
4.1.2 SetConversationMaxSeq 完整调用链路

链路分析说明:整个调用链路会同时更新seq_user表和conversation表,两个表的逻辑完全一样。根据代码分析,conversation表的更新主要是为了兼容老版本逻辑,实际的权限控制以seq_user表为准。这种设计确保了系统升级的平滑过渡。

// 第一层:Conversation服务接口层
// 文件:open-im-server/internal/rpc/conversation/conversation.go:762
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error) {
    // 步骤1:更新用户序号缓存和数据库
    if err := c.msgClient.SetUserConversationMaxSeq(ctx, req.ConversationID, req.OwnerUserID, req.MaxSeq); err != nil {
        return nil, err
    }
    
    // 步骤2:更新会话表中的max_seq字段
    if err := c.conversationDatabase.UpdateUsersConversationField(ctx, req.OwnerUserID, req.ConversationID,
        map[string]any{"max_seq": req.MaxSeq}); err != nil {
        return nil, err
    }
    
    // 步骤3:发送变更通知,触发多端同步
    for _, userID := range req.OwnerUserID {
        c.conversationNotificationSender.ConversationChangeNotification(ctx, userID, []string{req.ConversationID})
    }
    return &pbconversation.SetConversationMaxSeqResp{}, nil
}
// 第二层:消息服务序号更新
// 文件:open-im-server/internal/rpc/msg/seq.go:82
func (m *msgServer) SetUserConversationMaxSeq(ctx context.Context, req *pbmsg.SetUserConversationMaxSeqReq) (*pbmsg.SetUserConversationMaxSeqResp, error) {
    for _, userID := range req.OwnerUserID {
        // 为每个用户更新该会话的最大序列号
        if err := m.MsgDatabase.SetUserConversationsMaxSeq(ctx, req.ConversationID, userID, req.MaxSeq); err != nil {
            return nil, err
        }
    }
    return &pbmsg.SetUserConversationMaxSeqResp{}, nil
}
// 第三层:数据存储控制器
// 文件:open-im-server/pkg/common/storage/controller/msg.go:607
func (db *commonMsgDatabase) SetUserConversationsMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
    return db.seqUser.SetUserMaxSeq(ctx, conversationID, userID, seq)
}
// 第四层:Redis缓存层
// 文件:open-im-server/pkg/common/storage/cache/redis/seq_user.go:51
func (s *seqUserCacheRedis) SetUserMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
    // 先更新MongoDB数据库
    if err := s.mgo.SetUserMaxSeq(ctx, conversationID, userID, seq); err != nil {
        return err
    }
    // 然后清理Redis缓存,确保下次读取最新数据
    return s.rocks.TagAsDeleted2(ctx, s.getSeqUserMaxSeqKey(conversationID, userID))
}
// 第五层:MongoDB数据层
// 文件:open-im-server/pkg/common/storage/database/mgo/seq_user.go:71
func (s *seqUserMongo) SetUserMaxSeq(ctx context.Context, conversationID string, userID string, seq int64) error {
    return s.setSeq(ctx, conversationID, userID, seq, "max_seq")
}

func (s *seqUserMongo) setSeq(ctx context.Context, conversationID string, userID string, seq int64, field string) error {
    filter := map[string]any{
        "user_id":         userID,
        "conversation_id": conversationID,
    }
    insert := bson.M{
        "user_id":         userID,
        "conversation_id": conversationID,
        "min_seq":         0,
        "max_seq":         0,
        "read_seq":        0,
    }
    delete(insert, field)
    update := map[string]any{
        "$set": bson.M{
            field: seq,  // 更新max_seq字段
        },
        "$setOnInsert": insert,
    }
    opt := options.Update().SetUpsert(true)
    return mongoutil.UpdateOne(ctx, s.coll, filter, update, false, opt)
}
4.1.3 UpdateUsersConversationField 会话表更新机制
// 文件:open-im-server/pkg/common/storage/controller/conversation.go:126
func (c *conversationDatabase) UpdateUsersConversationField(ctx context.Context, userIDs []string, conversationID string, args map[string]any) error {
    // 第一步:批量更新会话字段
    _, err := c.conversationDB.UpdateByMap(ctx, userIDs, conversationID, args)
    if err != nil {
        return err
    }

    // 第二步:清理相关缓存
    cache := c.cache.CloneConversationCache()
    cache = cache.DelUsersConversation(conversationID, userIDs...).DelConversationVersionUserIDs(userIDs...)

    // 第三步:根据更新的字段类型清理对应缓存
    if _, ok := args["recv_msg_opt"]; ok {
        cache = cache.DelConversationNotReceiveMessageUserIDs(conversationID)
        cache = cache.DelConversationNotNotifyMessageUserIDs(userIDs...)
    }
    if _, ok := args["is_pinned"]; ok {
        cache = cache.DelConversationPinnedMessageUserIDs(userIDs...)
    }
    
    return cache.ChainExecDel(ctx)
}
// 文件:open-im-server/pkg/common/storage/database/mgo/conversation.go:77
func (c *ConversationMgo) UpdateByMap(ctx context.Context, userIDs []string, conversationID string, args map[string]any) (int64, error) {
    if len(args) == 0 || len(userIDs) == 0 {
        return 0, nil
    }
    filter := bson.M{
        "conversation_id": conversationID,
        "owner_user_id":   bson.M{"$in": userIDs},
    }
    var rows int64
    err := mongoutil.IncrVersion(func() error {
        // 批量更新多个用户的会话记录
        res, err := mongoutil.UpdateMany(ctx, c.coll, filter, bson.M{"$set": args})
        if err != nil {
            return err
        }
        rows = res.ModifiedCount
        return nil
    }, func() error {
        // 更新版本信息,用于增量同步
        for _, userID := range userIDs {
            if err := c.version.IncrVersion(ctx, userID, []string{conversationID}, model.VersionStateUpdate); err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        return 0, err
    }
    return rows, nil
}

4.2 退群处理完整流程

// 文件:open-im-server/internal/rpc/group/group.go:1593
func (g *groupServer) QuitGroup(ctx context.Context, req *pbgroup.QuitGroupReq) (*pbgroup.QuitGroupResp, error) {
    // 1. 权限验证:检查群主不能退出群组
    if member.RoleLevel == constant.GroupOwner {
        return nil, errs.ErrNoPermission.WrapMsg("group owner can't quit")
    }

    // 2. 从群组中删除该成员
    err = g.db.DeleteGroupMember(ctx, req.GroupID, []string{req.UserID})
    if err != nil {
        return nil, err
    }

    // 3. 发送成员退出通知
    g.notification.MemberQuitNotification(ctx, g.groupMemberDB2PB(member, 0))

    // 4. 关键:设置用户的会话序列号,确保退出后不能看到后续消息
    if err := g.deleteMemberAndSetConversationSeq(ctx, req.GroupID, []string{req.UserID}); err != nil {
        return nil, err
    }

    return &pbgroup.QuitGroupResp{}, nil
}

4.3 踢人场景的序号处理

// 文件:open-im-server/internal/push/push_handler.go:351
func (c *ConsumerHandler) groupMessagesHandler(ctx context.Context, groupID string, pushToUserIDs *[]string, msg *sdkws.MsgData) (err error) {
    switch msg.ContentType {
    case constant.MemberQuitNotification:
        var tips sdkws.MemberQuitTips
        if unmarshalNotificationElem(msg.Content, &tips) != nil {
            return err
        }
        // 退群时设置序号
        if err = c.DeleteMemberAndSetConversationSeq(ctx, groupID, []string{tips.QuitUser.UserID}); err != nil {
            log.ZError(ctx, "MemberQuitNotification DeleteMemberAndSetConversationSeq", err)
        }
        
    case constant.MemberKickedNotification:
        var tips sdkws.MemberKickedTips
        if unmarshalNotificationElem(msg.Content, &tips) != nil {
            return err
        }
        kickedUsers := datautil.Slice(tips.KickedUserList, func(e *sdkws.GroupMemberFullInfo) string { return e.UserID })
        // 踢人时批量设置序号
        if err = c.DeleteMemberAndSetConversationSeq(ctx, groupID, kickedUsers); err != nil {
            log.ZError(ctx, "MemberKickedNotification DeleteMemberAndSetConversationSeq", err)
        }
}

4.4 退群序号控制架构

架构图说明:下图展示了退群时序号设置的完整流程,从触发操作到最终的数据库更新和重新加入的效果控制。

flowchart TB
    subgraph "退群触发层"
        A[QuitGroup请求] --> C[MemberQuitNotification]
        B[KickGroupMember请求] --> C
    end
    
    subgraph "序号设置层"
        C --> D[deleteMemberAndSetConversationSeq]
        D --> E[GetConversationMaxSeq<br/>获取群组当前最大序号]
        E --> F[SetConversationMaxSeq<br/>设置用户最大序号]
    end
    
    subgraph "数据层处理"
        F --> G[SetUserConversationMaxSeq<br/>更新seq_user表]
        F --> H[UpdateUsersConversationField<br/>更新conversation表兼容]
        G --> I[Redis缓存层]
        H --> J[MongoDB存储层]
    end
    
    subgraph "版本控制层"
        J --> K[IncrVersion版本递增]
        K --> L[ConversationChangeNotification<br/>多端同步通知]
    end
    
    subgraph "效果控制层"
        M[用户重新加入群组] --> N[SetUserConversationsMinSeq<br/>设置userMinSeq = userMaxSeq + 1]
        N --> O[形成序号隔离<br/>无法看到退群前消息]
    end
    
    L --> M
    
    style D fill:#ffcccc
    style F fill:#ffffcc  
    style N fill:#ccffcc
    style O fill:#ccccff

4.5 SetConversationMaxSeq方法详解

4.5.1 方法签名和参数
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error)

// 请求参数结构
type SetConversationMaxSeqReq struct {
    ConversationID string   // 会话ID
    OwnerUserID    []string // 用户ID列表(支持批量操作)
    MaxSeq         int64    // 要设置的最大序列号
}
4.5.2 核心处理逻辑
func (c *conversationServer) SetConversationMaxSeq(ctx context.Context, req *pbconversation.SetConversationMaxSeqReq) (*pbconversation.SetConversationMaxSeqResp, error) {
    // ========== 第一步:更新用户序号系统 ==========
    // 调用消息服务,更新seq_user表中的max_seq字段
    if err := c.msgClient.SetUserConversationMaxSeq(ctx, req.ConversationID, req.OwnerUserID, req.MaxSeq); err != nil {
        return nil, err
    }
    
    // ========== 第二步:更新会话表(兼容性) ==========
    // 更新conversation表中的max_seq字段,主要为兼容老版本,实际权限控制以seq_user表为准
    if err := c.conversationDatabase.UpdateUsersConversationField(ctx, req.OwnerUserID, req.ConversationID,
        map[string]any{"max_seq": req.MaxSeq}); err != nil {
        return nil, err
    }
    
    // ========== 第三步:多端同步通知 ==========
    // 发送会话变更通知,确保用户的所有设备都能及时同步
    for _, userID := range req.OwnerUserID {
        c.conversationNotificationSender.ConversationChangeNotification(ctx, userID, []string{req.ConversationID})
    }
    
    return &pbconversation.SetConversationMaxSeqResp{}, nil
}
4.5.3 UpdateUsersConversationField详解
// 文件:open-im-server/pkg/common/storage/controller/conversation.go:126
func (c *conversationDatabase) UpdateUsersConversationField(ctx context.Context, userIDs []string, conversationID string, args map[string]any) error {
    // ========== 第一步:执行数据库更新 ==========
    _, err := c.conversationDB.UpdateByMap(ctx, userIDs, conversationID, args)
    if err != nil {
        return err
    }

    // ========== 第二步:缓存管理 ==========
    cache := c.cache.CloneConversationCache()
    cache = cache.DelUsersConversation(conversationID, userIDs...).DelConversationVersionUserIDs(userIDs...)

    // ========== 第三步:智能缓存清理 ==========
    // 根据更新的字段类型,选择性清理相关缓存
    if _, ok := args["recv_msg_opt"]; ok {
        // 消息接收选项变更,清理消息推送相关缓存
        cache = cache.DelConversationNotReceiveMessageUserIDs(conversationID)
        cache = cache.DelConversationNotNotifyMessageUserIDs(userIDs...)
    }
    if _, ok := args["is_pinned"]; ok {
        // 置顶状态变更,清理置顶列表缓存
        cache = cache.DelConversationPinnedMessageUserIDs(userIDs...)
    }
    
    return cache.ChainExecDel(ctx)
}
4.5.4 MongoDB UpdateByMap实现
// 文件:open-im-server/pkg/common/storage/database/mgo/conversation.go:77
func (c *ConversationMgo) UpdateByMap(ctx context.Context, userIDs []string, conversationID string, args map[string]any) (int64, error) {
    if len(args) == 0 || len(userIDs) == 0 {
        return 0, nil
    }
    
    // ========== 构建查询条件 ==========
    filter := bson.M{
        "conversation_id": conversationID,
        "owner_user_id":   bson.M{"$in": userIDs}, // 批量更新指定用户列表
    }
    
    var rows int64
    
    // ========== 使用版本控制的事务更新 ==========
    err := mongoutil.IncrVersion(
        // 数据更新函数
        func() error {
            res, err := mongoutil.UpdateMany(ctx, c.coll, filter, bson.M{"$set": args})
            if err != nil {
                return err
            }
            rows = res.ModifiedCount
            return nil
        },
        // 版本更新函数
        func() error {
            for _, userID := range userIDs {
                if err := c.version.IncrVersion(ctx, userID, []string{conversationID}, model.VersionStateUpdate); err != nil {
                    return err
                }
            }
            return nil
        },
    )
    
    return rows, err
}

4.6 序号设置的关键数据变化

阶段seq_user表seq表效果
退群前max_seq: 1200
min_seq: 1
read_seq: 1200
max_seq: 1200
min_seq: 1
可见消息1-1200
退群时max_seq: 1500
min_seq: 1
read_seq: 1200
max_seq: 1500
min_seq: 1
锁定在1500
重新加入max_seq: 1500
min_seq: 1501
read_seq: 1500
max_seq: 1500
min_seq: 1501
看不到1500前的消息

4.7 退群机制的技术亮点

4.7.1 双重序号锁定机制
  • MaxSeq锁定:退群时锁定用户的最大可见序号
  • MinSeq控制:重新加入时设置最小序号,形成权限隔离
4.7.2 多层存储一致性
  • seq_user表:用户级别的序号控制
  • seq表:会话级别的状态管理
  • Redis缓存:高性能数据访问
  • 版本控制:支持增量同步

👥 第五部分:新成员历史消息控制

5.1 EnableHistoryForNewMembers配置

新成员是否能看到历史消息由EnableHistoryForNewMembers配置控制:

// 文件:open-im-server/internal/rpc/group/notification.go:1568
func (g *NotificationSender) groupApplicationAgreeMemberEnterNotification(ctx context.Context, groupID string, SendMessage *bool, invitedOpUserID string, entrantUserID ...string) error {
    if !g.config.RpcConfig.EnableHistoryForNewMembers {
        // 新成员不能看历史消息:设置MinSeq为当前MaxSeq+1
        conversationID := msgprocessor.GetConversationIDBySessionType(constant.ReadGroupChatType, groupID)
        maxSeq, err := g.msgClient.GetConversationMaxSeq(ctx, conversationID)
        if err != nil {
            return err
        }
        // 关键:设置新成员的userMinSeq为conversationMaxSeq+1,隐藏历史消息
        if err := g.msgClient.SetUserConversationsMinSeq(ctx, conversationID, entrantUserID, maxSeq+1); err != nil {
            return err
        }
    }
    
    // 为新成员创建群组会话
    if err := g.conversationClient.CreateGroupChatConversations(ctx, groupID, entrantUserID); err != nil {
        return err
    }
}

5.2 会话创建时间控制

// 文件:open-im-server/pkg/common/storage/controller/conversation.go:446
func (c *conversationDatabase) CreateGroupChatConversation(ctx context.Context, groupID string, userIDs []string) error {
    conversations := make([]*relationtb.Conversation, 0, len(userIDs))
    now := time.Now()  // 关键:使用当前时间,而非群创建时间
    
    for _, userID := range userIDs {
        conversations = append(conversations, &relationtb.Conversation{
            OwnerUserID:      userID,
            ConversationID:   conversationID,
            ConversationType: constant.GroupChatType,
            GroupID:          groupID,
            CreateTime:       now,  // 会话创建时间为当前时间
            // ... 其他字段
        })
    }
    
    return c.conversationDB.Create(ctx, conversations)
}

5.3 历史消息权限控制对比

配置EnableHistoryForNewMembers新成员userMinSeq设置历史消息可见性适用场景
允许true不设置(默认为1)可见所有历史消息开放性社群、学习群
禁止falseconversationMaxSeq + 1只能看加入后消息私密群、工作群

5.4 新成员权限设置流程

graph TB
    subgraph "成员加入触发"
        A[申请入群] --> B[管理员同意]
        C[成员邀请] --> B
        B --> D[groupApplicationAgreeMemberEnterNotification]
    end
    
    subgraph "历史权限判断"
        D --> E{EnableHistoryForNewMembers?}
        E --> |true| F[允许查看历史消息]
        E --> |false| G[禁止查看历史消息]
    end
    
    subgraph "禁止历史的实现"
        G --> H[GetConversationMaxSeq]
        H --> I[当前群最大序号: maxSeq]
        I --> J[SetUserConversationsMinSeq]
        J --> K[新成员userMinSeq = conversationMaxSeq + 1]
    end
    
    subgraph "会话创建"
        F --> L[CreateGroupChatConversations]
        K --> L
        L --> M[创建个人会话记录]
        M --> N[CreateTime = 当前时间]
    end
    
    style G fill:#ff9999
    style K fill:#ffcccc

📨 第六部分:消息拉取逻辑详解

6.1 消息拉取范围计算

6.1.1 四层边界检查机制

OpenIM采用四层边界检查机制来精确控制用户的消息可见范围:

// 文件:open-im-server/pkg/common/storage/controller/msg.go:632
func (db *commonMsgDatabase) getMsgBySeqsRange(ctx context.Context, userID, conversationID string, begin, end int64, maxNum int64, maxSeq int64) (minSeq, maxSeq int64, seqMsg []*sdkws.MsgData, err error) {
    // ========== 第一层:获取会话级边界 ==========
    conversation, err := db.conversationCache.GetConversationByID(ctx, conversationID)
    if err != nil {
        return 0, 0, nil, err
    }
    
    // 会话最小序号(全局下边界)
    conversationMinSeq := conversation.MinSeq
    if conversationMinSeq < 1 {
        conversationMinSeq = 1
    }
    
    // 会话最大序号(全局上边界)
    conversationMaxSeq := conversation.MaxSeq
    
    // ========== 第二层:获取用户级边界 ==========
    // 用户最小序号(个人下边界,主要用于新成员控制)
    userMinSeq, err := db.seqUserCache.GetUserMinSeq(ctx, conversationID, userID)
    if err != nil {
        userMinSeq = conversationMinSeq // 默认使用会话最小序号
    }
    
    // 用户最大序号(个人上边界,主要用于退群用户控制)
    userMaxSeq, err := db.seqUserCache.GetUserMaxSeq(ctx, conversationID, userID)
    if err != nil {
        userMaxSeq = conversationMaxSeq // 默认使用会话最大序号
    }
    
    // ========== 第三层:计算有效范围 ==========
    // 取更严格的边界
    effectiveMinSeq := max(conversationMinSeq, userMinSeq)
    effectiveMaxSeq := min(conversationMaxSeq, userMaxSeq, maxSeq) // maxSeq是请求参数中的限制
    
    // ========== 第四层:序号过滤 ==========
    validSeqs := make([]int64, 0)
    for seq := begin; seq <= end && len(validSeqs) < int(maxNum); seq++ {
        if seq >= effectiveMinSeq && seq <= effectiveMaxSeq {
            validSeqs = append(validSeqs, seq)
        }
    }
    
    return effectiveMinSeq, effectiveMaxSeq, db.getMsgsBySeqs(ctx, conversationID, validSeqs)
}
6.1.2 范围计算流程图
graph TD
    A[GetUserMinSeq] --> E[边界计算]
    B[GetConversationMinSeq] --> E
    C[GetConversationMaxSeq] --> E  
    D[GetUserMaxSeq] --> E
    
    E --> F[计算effectiveMinSeq]
    F --> G[计算effectiveMaxSeq]
    G --> H[序号范围过滤]
    
    H --> I{在范围内?}
    I -->|是| J[添加到列表]
    I -->|否| K[跳过该序号]
    
    J --> L[返回消息列表]
    K --> L

四层边界检查逻辑

层级操作输入输出说明
第一层获取会话级边界conversationIDconversationMinSeq, conversationMaxSeq全局消息范围
第二层获取用户级边界userID, conversationIDuserMinSeq, userMaxSeq个人权限范围
第三层计算有效范围上述四个值effectiveMinSeq, effectiveMaxSeq取更严格的边界
第四层序号过滤begin, end, effectiveRangevalidSeqs[]只返回有效范围内的序号

计算公式

  • effectiveMinSeq = max(conversationMinSeq, userMinSeq)
  • effectiveMaxSeq = min(conversationMaxSeq, userMaxSeq)
  • validSeq = seq >= effectiveMinSeq && seq <= effectiveMaxSeq
6.1.3 边界控制场景示例
场景conversationMinSeqconversationMaxSequserMinSequserMaxSeq有效范围说明
普通成员12000--1-2000可见所有历史消息
新成员120001500-1500-2000只能看加入后的消息
退群用户12000-12001-1200只能看退群前的消息
消息清理后5002000--500-2000早期消息已清理
复杂情况50020008001500800-1500多重限制叠加
6.1.4 分页控制与边界处理
// 文件:open-im-server/internal/rpc/msg/sync_msg.go:89
func (m *msgServer) handleSeqRange(ctx context.Context, userID, conversationID string, begin, end, num int64, order sdkws.PullOrder) (*sdkws.PullMessageBySeqsResp_Msgs, error) {
    // 获取用户权限信息
    conversation, err := m.ConversationLocalCache.GetConversation(ctx, userID, conversationID)
    if err != nil {
        return nil, err
    }
    
    // 应用用户最大序号限制
    userMaxSeq := conversation.MaxSeq
    if end > userMaxSeq {
        end = userMaxSeq
        // 设置边界到达标志
        isEnd = true
    }
    
    // 应用用户最小序号限制  
    userMinSeq := conversation.MinSeq
    if userMinSeq == 0 {
        userMinSeq = 1
    }
    if begin < userMinSeq {
        begin = userMinSeq
        isEnd = true
    }
    
    // 拉取消息
    _, _, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, userID, conversationID, begin, end, num, userMaxSeq)
    
    return &sdkws.PullMessageBySeqsResp_Msgs{
        Msgs:   msgs,
        IsEnd:  isEnd,
        MinSeq: begin,
        MaxSeq: end,
    }, nil
}

6.2 分页拉取的序号控制

// 文件:open-im-server/internal/rpc/msg/sync_msg.go:40
func (m *msgServer) PullMessageBySeqs(ctx context.Context, req *sdkws.PullMessageBySeqsReq) (*sdkws.PullMessageBySeqsResp, error) {
    for _, seq := range req.SeqRanges {
        if !msgprocessor.IsNotification(seq.ConversationID) {
            // 获取用户在该会话中的权限信息
            conversation, err := m.ConversationLocalCache.GetConversation(ctx, req.UserID, seq.ConversationID)
            if err != nil {
                continue
            }

            // 按序列号范围拉取消息,使用conversation.MaxSeq进行权限控制
            minSeq, maxSeq, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, req.UserID, seq.ConversationID,
                seq.Begin, seq.End, seq.Num, conversation.MaxSeq)
                
            // 判断是否到达边界
            var isEnd bool
            if req.Order == sdkws.PullOrder_PullOrderDesc {
                isEnd = minSeq <= seq.Begin
            } else {
                isEnd = maxSeq >= seq.End  
            }
        }
    }
}

🚫 第七部分:消息撤回处理机制

7.1 消息撤回权限验证

// 文件:open-im-server/internal/rpc/msg/revoke.go:43
func (m *msgServer) RevokeMsg(ctx context.Context, req *msg.RevokeMsgReq) (*msg.RevokeMsgResp, error) {
    // 获取要撤回的消息
    _, _, msgs, err := m.MsgDatabase.GetMsgBySeqs(ctx, req.UserID, req.ConversationID, []int64{req.Seq})
    
    // 根据会话类型进行权限验证
    var role int32
    switch sessionType := msgs[0].SessionType; sessionType {
    case constant.SingleChatType:
        // 单聊:只有发送者可以撤回
        if msgs[0].SendID != req.UserID {
            return nil, errs.ErrNoPermission.WrapMsg("you can only revoke your own messages")
        }
        role = 0
        
    case constant.ReadGroupChatType:
        if msgs[0].SendID == req.UserID {
            // 群聊:发送者可以撤回自己的消息
            member, err := m.GroupLocalCache.GetGroupMember(ctx, msgs[0].GroupID, req.UserID)
            if err != nil {
                return nil, err
            }
            role = member.RoleLevel
        } else {
            // 群聊:群主和管理员可以撤回他人消息
            member, err := m.GroupLocalCache.GetGroupMember(ctx, msgs[0].GroupID, req.UserID)
            if err != nil {
                return nil, err
            }
            if member.RoleLevel <= constant.GroupOrdinaryUsers {
                return nil, errs.ErrNoPermission.WrapMsg("only group admin can revoke other's message")
            }
            role = member.RoleLevel
        }
    }
}

7.2 撤回信息记录机制

// 文件:open-im-server/pkg/common/storage/controller/msg.go:271
func (db *commonMsgDatabase) RevokeMsg(ctx context.Context, conversationID string, seq int64, revoke *model.RevokeModel) error {
    // 将撤回信息插入到消息文档中
    if err := db.batchInsertBlock(ctx, conversationID, []any{revoke}, updateKeyRevoke, seq); err != nil {
        return err
    }
    // 清理缓存,确保下次拉取时获取最新状态
    return db.msgCache.DelMessageBySeqs(ctx, conversationID, []int64{seq})
}

// RevokeModel结构
type RevokeModel struct {
    Role     int32  `bson:"role"`      // 撤回者角色
    UserID   string `bson:"user_id"`   // 撤回者用户ID  
    Nickname string `bson:"nickname"`  // 撤回者昵称
    Time     int64  `bson:"time"`      // 撤回时间
}

7.3 撤回消息过滤处理

// 文件:open-im-server/pkg/common/storage/controller/msg.go:771
func (db *commonMsgDatabase) handlerDeleteAndRevoked(ctx context.Context, userID string, msgs []*model.MsgInfoModel) {
    for i := range msgs {
        msg := msgs[i]
        if msg.Revoke == nil {
            continue
        }
        
        // 将消息类型设置为撤回通知
        msg.Msg.ContentType = constant.MsgRevokeNotification
        
        // 构造撤回内容
        revokeContent := sdkws.MessageRevokedContent{
            RevokerID:                   msg.Revoke.UserID,
            RevokerRole:                 msg.Revoke.Role,
            ClientMsgID:                 msg.Msg.ClientMsgID,
            RevokerNickname:             msg.Revoke.Nickname,
            RevokeTime:                  msg.Revoke.Time,
            SourceMessageSendTime:       msg.Msg.SendTime,
            SourceMessageSendID:         msg.Msg.SendID,
            SourceMessageSenderNickname: msg.Msg.SenderNickname,
            SessionType:                 msg.Msg.SessionType,
            Seq:                         msg.Msg.Seq,
            Ex:                          msg.Msg.Ex,
        }
        
        // 将撤回信息序列化为消息内容
        data, _ := jsonutil.JsonMarshal(&revokeContent)
        elem := sdkws.NotificationElem{Detail: string(data)}
        content, _ := jsonutil.JsonMarshal(&elem)
        msg.Msg.Content = string(content)
    }
}

7.4 引用消息的撤回处理

// 文件:openim-sdk-core/internal/conversation_msg/revoke.go:123
func (c *Conversation) quoteMsgRevokeHandle(ctx context.Context, conversationID string, v *model_struct.LocalChatLog, revokedMsg sdk_struct.MessageRevoked) error {
    s := sdk_struct.QuoteElem{}
    if err := utils.JsonStringToStruct(v.Content, &s); err != nil {
        return errs.New("ChatLog content transfer failed.")
    }

    if s.QuoteMessage == nil {
        return errs.New("QuoteMessage is nil").Wrap()
    }
    
    // 检查引用消息是否为被撤回的消息
    if s.QuoteMessage.ClientMsgID != revokedMsg.ClientMsgID {
        return nil
    }

    // 更新引用消息的内容为撤回信息
    s.QuoteMessage.Content = utils.StructToJsonString(revokedMsg)
    s.QuoteMessage.ContentType = constant.RevokeNotification
    v.Content = utils.StructToJsonString(s)
    
    // 更新数据库中的引用消息
    if err := c.db.UpdateMessageBySeq(ctx, conversationID, v); err != nil {
        return errs.Wrap(err)
    }
    return nil
}

7.5 消息撤回完整流程

graph TB
    subgraph "撤回请求处理"
        A[RevokeMsg请求] --> B[参数验证]
        B --> C[消息查找]
        C --> D[权限验证]
    end
    
    subgraph "权限验证分支"
        D --> E{会话类型}
        E --> |单聊| F[发送者验证]
        E --> |群聊| G{撤回者身份}
        G --> |发送者本人| H[允许撤回]
        G --> |群管理员| I[管理员权限验证]
        F --> J[权限通过]
        H --> J
        I --> J
    end
    
    subgraph "撤回执行"
        J --> K[RevokeMsg数据库操作]
        K --> L[插入撤回记录]
        L --> M[清理消息缓存]
        M --> N[发送撤回通知]
    end
    
    subgraph "消息过滤处理"
        O[GetMessageBySeqs] --> P[handlerDeleteAndRevoked]
        P --> Q{存在Revoke记录?}
        Q --> |是| R[转换为撤回通知]
        Q --> |否| S[返回原始消息]
        R --> T[更新引用消息]
    end
    
    style D fill:#ffffcc
    style K fill:#ffcccc
    style P fill:#ccffcc

🔗 第八部分:序号关系图谱与场景分析

8.1 序号关系矩阵

序号类型作用域设置时机影响范围主要用途
ConversationMaxSeq全群消息发送时递增所有成员消息排序、同步边界
ConversationMinSeq全群数据清理时设置所有成员历史消息隐藏
UserMaxSeq单用户退群时设置特定用户退群权限隔离
UserMinSeq单用户入群时设置特定用户历史权限控制
ReadSeq单用户阅读消息时更新特定用户未读数计算

8.2 复杂场景的序号状态变化

场景1:新成员加入群聊
sequenceDiagram
    participant User as 新用户
    participant Group as 群聊(MaxSeq: 1000)
    participant System as 系统
    
    User->>Group: 申请加入群聊
    Group->>System: 同意申请
    System->>System: 检查EnableHistoryForNewMembers
    alt 禁止查看历史
        System->>User: SetUserMinSeq(1001)
        Note over User: 只能看到1001之后的消息
    else 允许查看历史  
        System->>User: UserMinSeq = 1
        Note over User: 可以看到所有历史消息
    end
    System->>User: CreateConversation
    Note over User: 会话创建时间为当前时间
场景2:用户退群重新加入
sequenceDiagram
    participant User as 用户
    participant Group as 群聊
    participant System as 系统
    
    Note over Group: 当前MaxSeq: 1000
    User->>Group: 退出群聊
    System->>User: SetUserMaxSeq(1000)
    Note over User: 最大可见序号锁定为1000
    
    Note over Group: 群聊继续,MaxSeq增长到1200
    User->>Group: 重新加入群聊
    System->>User: SetUserMinSeq(1201)
    Note over User: 最小可见序号为1201
    Note over User: 1001-1200期间消息不可见
场景3:消息删除的多设备同步
sequenceDiagram
    participant Device1 as 设备1
    participant Device2 as 设备2  
    participant Server as 服务器
    participant DB as 数据库
    
    Device1->>Server: DeleteMsgs(逻辑删除)
    Server->>DB: PushUnique(del_list, userID)
    Server->>Device1: 删除成功
    Server->>Device2: DeleteMsgsTips通知
    Device2->>Device2: 本地标记消息已删除
    
    Note over Device1,Device2: 两设备消息状态同步
    Device2->>Server: GetMessageBySeqs
    Server->>DB: 检查del_list
    DB->>Server: 返回空内容+删除状态
    Server->>Device2: MsgDeleted状态消息

8.3 未读数计算的序号逻辑

// 未读数计算公式
unreadCount = MaxSeq - ReadSeq

// 但需要考虑用户权限边界
if UserMaxSeq > 0 && UserMaxSeq < MaxSeq {
    effectiveMaxSeq = UserMaxSeq
} else {
    effectiveMaxSeq = MaxSeq
}

if UserMinSeq > ReadSeq {
    effectiveReadSeq = UserMinSeq - 1  // 历史消息视为已读
} else {
    effectiveReadSeq = ReadSeq
}

unreadCount = effectiveMaxSeq - effectiveReadSeq

8.4 读扩散机制总结图

flowchart TB
    subgraph "消息存储层MongoDB"
        A[单条消息存储] --> B[分配全局唯一seq]
        B --> C[消息内容统一管理]
    end
    
    subgraph "用户权限层Redis"
        D[UserMinSeq控制] --> F[个性化可见范围]
        E[UserMaxSeq控制] --> F
        F --> G[ReadSeq管理]
    end
    
    subgraph "会话记录层MySQL"
        H[每用户独立会话] --> I[个性化设置]
        I --> J[置顶免打扰等]
        I --> K[未读数独立计算]
    end
    
    subgraph "多设备同步层"
        L[del_list数组] --> M[删除状态同步]
        N[revoke记录] --> O[撤回状态同步]
        P[序号边界] --> Q[权限状态同步]
    end
    
    A --> D
    C --> H
    F --> L
    G --> N
    
    style A fill:#e1f5fe
    style F fill:#f3e5f5
    style I fill:#e8f5e8
    style M fill:#fff3e0

📝 总结

OpenIM的群聊读扩散机制通过以下7个核心机制实现了复杂场景下的精确控制:

关键技术要点

  1. 双重删除机制:逻辑删除(del_list)与物理删除并存,支持个人删除和全员删除
  2. 管理员硬删除:通过DestructMsgs实现物理删除和conversationMinSeq边界控制,释放存储空间
  3. 五层序号控制:conversationMinSeq、conversationMaxSeq、userMinSeq、userMaxSeq、userReadSeq精确权限管理
  4. 权限隔离设计:退群用户通过userMaxSeq锁定,新成员通过userMinSeq隔离,硬删除通过conversationMinSeq全局控制
  5. 多设备同步:基于通知机制的删除、撤回状态实时同步
  6. 读扩散架构:消息统一存储,会话记录分离,权限个性化管理
  7. 状态标记系统:del_list、revoke记录、序号边界的组合式状态管理

这套机制为大规模IM系统提供了完整的群聊读扩散解决方案,在保证功能完整性的同时,实现了高性能和高可用性。