单聊(第三阶段)消息接收流程
概述
第三阶段负责在设备端接收并处理服务端推送的消息。本阶段采用三协程架构确保消息处理的高效性和稳定性。
三协程架构设计原理
OpenIM采用三个专门的协程来协调处理消息接收事件:
-
消息读取协程(readPump):
- 专门负责从WebSocket连接中读取消息
- 不进行复杂的业务逻辑处理,确保读取效率
- 将消息快速分发到对应的处理通道
-
消息同步协程(DoListener):
- 专门负责处理消息同步逻辑
- 处理连续性检查、缺失消息补偿等复杂业务
- 避免阻塞消息读取流程
-
会话协程(ConsumeConversationEventLoop):
- 专门负责处理会话相关的所有事件
- 处理消息存储、会话更新、UI通知等业务
- 与消息同步协程通过事件队列解耦
三协程分工协作的优势:
- 完全解耦读取、同步和处理:避免任何环节阻塞整个流程
- 提高并发性能:三个协程可以并行工作,最大化处理效率
- 增强稳定性:任何一个协程异常不会影响其他协程
- 便于扩展:可以独立优化每个环节的处理逻辑
- 清晰的职责边界:每个协程专注于特定的功能领域
核心流程图
设备端消息接收核心流程图
graph TD
A[服务端推送消息] --> B[readPump读取协程]
B --> C[handleMessage解析消息]
C --> D[发送到消息同步通道]
D --> E[DoListener同步协程]
E --> F[连续性检查]
F --> G{消息是否连续}
G -->|连续| H[直接处理消息]
G -->|不连续| I[拉取缺失消息]
I --> H
H --> J[发送到会话事件队列]
J --> K[会话协程处理]
K --> L[doMsgNew核心处理]
L --> M{发送者判断}
M -->|自己发送| N[处理自己的消息]
M -->|他人发送| O[处理他人的消息]
N --> P[消息去重和状态更新]
O --> Q[创建会话和未读计数]
P --> R[批量数据库操作]
Q --> R
R --> S[触发UI事件通知]
S --> T[用户看到新消息]
style A fill:#e3f2fd
style B fill:#f3e5f5
style E fill:#e8f5e8
style K fill:#fff3e0
style L fill:#ffebee
style T fill:#c8e6c9
关键技术点
- 消息连续性保证:通过序列号检查确保消息完整性
- 异常消息处理:handleExceptionMessages处理各种边界情况
- 会话状态同步:maxSeqRecorder和未读数管理
- 隐藏会话复活:保持用户设置的同时恢复会话显示
- 批量数据库操作:提高存储性能和事务完整性
- 事件驱动通知:区分前台后台,分别触发合适的通知方式
第一步:长连接读取推送消息
长连接管理器核心结构
负责维护WebSocket连接,提供消息读取的基础设施。
// LongConnMgr 长连接管理器
// 路径:openim-sdk-core/internal/interaction/long_conn_mgr.go:85-125
type LongConnMgr struct {
// 连接状态相关
w sync.Mutex // 连接状态互斥锁,保护connStatus字段
connStatus int // 连接状态:DefaultNotConnect/Closed/Connecting/Connected
// 核心连接组件
conn LongConn // 长连接接口,可以是TCP或WebSocket实现
listener func() open_im_sdk_callback.OnConnListener // 连接事件监听器回调函数
// 消息通道(三协程架构的核心)
send chan Message // 缓冲的出站消息通道
pushMsgAndMaxSeqCh chan common.Cmd2Value // 推送消息通道,分发给DoListener协程
conversationCh chan common.Cmd2Value // 会话相关消息通道,分发给会话协程
loginMgrCh chan common.Cmd2Value // 登录管理消息通道
// 消息处理组件
IsCompression bool // 是否启用消息压缩
Syncer *WsRespAsyn // WebSocket响应异步处理器
encoder Encoder // 消息编码器
compressor Compressor // 消息压缩器
reconnectStrategy ReconnectStrategy // 重连策略
}
readPump消息读取主循环
消息读取协程持续监听WebSocket连接,接收服务端推送的消息。
// readPump 消息读取主循环(消息读取协程)
// 路径:openim-sdk-core/internal/interaction/long_conn_mgr.go:242-341
func (c *LongConnMgr) readPump(ctx context.Context, fgCtx context.Context) {
// 设置消息处理回调
c.conn.SetPingHandler(c.pingHandler)
c.conn.SetPongHandler(c.pongHandler)
// 持续读取消息循环
for {
select {
case <-ctx.Done():
return
default:
// 设置读取超时时间,防止长时间阻塞
if err := c.conn.SetReadDeadline(readTimeout); err != nil {
log.ZError(ctx, "SetReadDeadline failed", err)
c.closedErr = err
return
}
// 从WebSocket连接读取消息
_, message, err := c.conn.ReadMessage()
if err != nil {
log.ZError(ctx, "ReadMessage failed", err)
c.closedErr = err
return
}
// 立即处理消息,不进行复杂业务逻辑
err = c.handleMessage(message)
if err != nil {
log.ZError(ctx, "handleMessage failed", err, "message", string(message))
}
}
}
}
handleMessage消息处理和路由
解析消息并将其分发到对应的处理通道,实现读取和处理的解耦。
// handleMessage 消息处理和路由
// 路径:openim-sdk-core/internal/interaction/long_conn_mgr.go:709-797
func (c *LongConnMgr) handleMessage(message []byte) error {
// 解压缩消息(如果启用压缩)
var binaryResp GeneralWsResp
if c.IsCompression {
message, err := c.compressor.DecompressWithPool(message)
if err != nil {
return errs.WrapMsg(err, "decompression failed")
}
}
// 解码消息
if err := c.encoder.Decode(message, &binaryResp); err != nil {
return errs.WrapMsg(err, "decode failed")
}
// 根据消息类型进行路由分发
switch binaryResp.ReqIdentifier {
case constant.WSPushMsg:
// 推送消息类型 - 分发给消息同步协程处理
return c.doPushMsg(ctx, binaryResp)
case constant.WSKickOnlineMsg:
// 设备踢下线通知
return c.handleUserOnlineChange(ctx, binaryResp)
// ... 其他消息类型
default:
// 响应类消息,交给异步响应处理器
return c.Syncer.NotifyResp(ctx, binaryResp)
}
}
doPushMsg消息分发机制
将推送消息封装后发送到消息同步器的处理通道。
// doPushMsg 消息分发机制
// 路径:openim-sdk-core/internal/interaction/long_conn_mgr.go:1016-1023
func (c *LongConnMgr) doPushMsg(ctx context.Context, wsResp GeneralWsResp) error {
// 解析推送消息
var pushMessages sdkws.PushMessages
if err := proto.Unmarshal(wsResp.Data, &pushMessages); err != nil {
return errs.WrapMsg(err, "unmarshal push messages failed")
}
// 🔄 协程切换点:发送到消息同步器通道,由消息同步协程处理
c.pushMsgAndMaxSeqCh <- common.Cmd2Value{
Cmd: constant.CmdPushMsg,
Value: &pushMessages,
Ctx: ctx,
}
return nil
}
第二步:消息同步器处理逻辑
MsgSyncer核心结构
消息同步器负责处理所有的消息同步逻辑,包括连续性检查和缺失消息补偿。
// MsgSyncer 消息同步器结构
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:61-85
type MsgSyncer struct {
loginUserID string // 登录用户ID
longConnMgr *LongConnMgr // 长连接管理器
// 消息接收和事件队列
recvCh chan common.Cmd2Value // 接收推送消息和最大SEQ号的通道
conversationEventQueue *common.EventQueue // 会话事件队列,用于存储和触发会话相关事件
// 同步状态管理
syncedMaxSeqs map[string]int64 // 所有会话ID对应的最大已同步SEQ号映射
syncedMaxSeqsLock sync.RWMutex // 保护syncedMaxSeqs映射的读写锁
// 数据存储和应用状态
db db_interface.DataBase // 数据存储接口
reinstalled bool // 标记应用是否被卸载后重新安装
// 同步控制
isSyncing bool // 标记是否正在进行数据同步
isSyncingLock sync.Mutex // 同步状态的互斥锁
}
LoadSeq并发初始化序列号状态
应用启动时,并发加载所有会话的最大同步序列号到内存。
// LoadSeq 并发初始化序列号状态
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:124-227
func (m *MsgSyncer) LoadSeq(ctx context.Context) error {
// 获取所有会话的最大同步序列号
conversations, err := m.db.GetAllConversations(ctx)
if err != nil {
return errs.WrapMsg(err, "GetAllConversations failed")
}
// 使用并发goroutine加载序列号,提高初始化性能
seqCh := make(chan SyncedSeq, len(conversations))
var wg sync.WaitGroup
// 并发处理每个会话的序列号加载
for _, conversation := range conversations {
wg.Add(1)
go func(conversationID string) {
defer wg.Done()
// 获取会话的最大同步序列号
maxSeq, err := m.db.GetConversationNormalMsgSeq(ctx, conversationID)
seqCh <- SyncedSeq{
ConversationID: conversationID,
MaxSyncedSeq: maxSeq,
Err: err,
}
}(conversation.ConversationID)
}
// 等待所有goroutine完成
go func() {
wg.Wait()
close(seqCh)
}()
// 收集结果并更新内存状态
m.syncedMaxSeqs = make(map[string]int64)
for result := range seqCh {
if result.Err != nil {
log.ZError(ctx, "GetConversationNormalMsgSeq failed", result.Err,
"conversationID", result.ConversationID)
continue
}
m.syncedMaxSeqs[result.ConversationID] = result.MaxSyncedSeq
}
return nil
}
DoListener监听器主循环
消息同步协程持续监听消息同步事件,是三协程架构中的核心处理协程。
// DoListener 监听器主循环(消息同步协程)
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:228-258
func (m *MsgSyncer) DoListener(ctx context.Context) {
// 持续监听各种同步事件
for {
select {
case <-ctx.Done():
return
case cmd := <-m.recvCh:
// 处理来自消息读取协程的消息和事件
m.handlePushMsgAndEvent(cmd)
}
}
}
handlePushMsgAndEvent事件分发逻辑
根据不同的命令类型,选择相应的处理方法。
// handlePushMsgAndEvent 事件分发逻辑
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:277-327
func (m *MsgSyncer) handlePushMsgAndEvent(cmd common.Cmd2Value) {
switch cmd.Cmd {
case constant.CmdConnSuccesss:
// 连接成功事件:建立连接后立即同步最新消息
log.ZInfo(cmd.Ctx, "recv long conn mgr connected", "cmd", cmd.Cmd, "value", cmd.Value)
if m.startSync() {
// 获取同步锁成功,开始连接后同步
m.doConnected(cmd.Ctx)
}
case constant.CmdWakeUpDataSync:
// 应用唤醒事件:从后台切换到前台时触发数据同步
log.ZInfo(cmd.Ctx, "app wake up, start sync msgs", "cmd", cmd.Cmd, "value", cmd.Value)
if m.startSync() {
m.doWakeupDataSync(cmd.Ctx)
}
case constant.CmdPushMsg:
// 推送消息事件:处理服务端推送的实时消息
m.doPushMsg(cmd.Ctx, cmd.Value.(*sdkws.PushMessages))
}
}
第三步:推送消息处理与连续性检查
doPushMsg处理推送消息
分别处理普通消息和通知消息,确保两种类型的消息都能正确同步。
🔄 推送消息处理中的关键属性变化:
- syncedMaxSeqs更新:内存中维护的各会话最大已同步序列号状态
- 消息连续性判断:基于MaxSeq进行消息连续性检查
- 分类处理策略:普通消息和通知消息分别处理,影响不同类型的会话状态
- 异步触发机制:通过triggerFunc回调异步更新会话和消息状态
// doPushMsg 处理推送消息
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:527-533
func (m *MsgSyncer) doPushMsg(ctx context.Context, push *sdkws.PushMessages) {
log.ZDebug(ctx, "push msgs", "push", push, "syncedMaxSeqs", m.syncedMaxSeqs)
// 处理普通会话消息(单聊、群聊)
m.pushTriggerAndSync(ctx, push.Msgs, m.triggerConversation)
// 处理通知消息(系统通知、入群通知等)
m.pushTriggerAndSync(ctx, push.NotificationMsgs, m.triggerNotification)
}
pushTriggerAndSync连续性检查核心算法
这是消息连续性保证的核心方法,确保消息的完整性和顺序性。
🔄 连续性检查中的MaxSeq状态管理:
- 内存MaxSeq更新:
m.syncedMaxSeqs[conversationID] = lastSeq实时更新会话最大序列号 - 连续性判断公式:
lastSeq == syncedMaxSeqs[conversationID] + len(storageMsgs)确保无消息丢失 - 缺失消息检测:
lastSeq > syncedMaxSeqs[conversationID]且不连续时触发补偿同步 - 序列号范围计算:
[syncedMaxSeqs + 1, lastSeq]精确定位需要补偿的消息范围 - 状态同步策略:连续消息立即更新状态,缺失消息等待补偿后统一更新
// pushTriggerAndSync 推送消息的触发和同步处理
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:545-589
func (m *MsgSyncer) pushTriggerAndSync(ctx context.Context, pushMessages map[string]*sdkws.PullMsgs, triggerFunc func(ctx context.Context, msgs map[string]*sdkws.PullMsgs) error) {
if len(pushMessages) == 0 {
return
}
needSyncSeqMap := make(map[string][2]int64) // 需要同步的序列号范围映射
var lastSeq int64 // 当前会话的最后一个序列号
var storageMsgs []*sdkws.MsgData // 当前会话的存储消息列表
// 遍历每个会话的推送消息
for conversationID, msgs := range pushMessages {
for _, msg := range msgs.Msgs {
// 序列号为0的消息(如系统消息)直接触发,不参与连续性检查
if msg.Seq == 0 {
_ = triggerFunc(ctx, map[string]*sdkws.PullMsgs{conversationID: {Msgs: []*sdkws.MsgData{msg}}})
continue
}
lastSeq = msg.Seq
storageMsgs = append(storageMsgs, msg)
}
// 检查消息连续性:如果最后一个序列号等于本地最大序列号加上消息数量,说明消息是连续的
if lastSeq == m.syncedMaxSeqs[conversationID]+int64(len(storageMsgs)) && lastSeq != 0 {
log.ZDebug(ctx, "trigger msgs", "msgs", storageMsgs)
// 消息连续,直接触发事件
_ = triggerFunc(ctx, map[string]*sdkws.PullMsgs{conversationID: {Msgs: storageMsgs}})
// 更新本地同步状态
m.syncedMaxSeqs[conversationID] = lastSeq
} else if lastSeq != 0 && lastSeq > m.syncedMaxSeqs[conversationID] {
// 消息不连续,需要拉取缺失的消息
needSyncSeqMap[conversationID] = [2]int64{m.syncedMaxSeqs[conversationID] + 1, lastSeq}
}
}
// 同步缺失的消息
m.syncAndTriggerMsgs(ctx, needSyncSeqMap, defaultPullNums)
}
连续性检查算法详解:
连续性检查的核心公式为:
lastSeq == syncedMaxSeqs[conversationID] + len(storageMsgs)
lastSeq:推送消息中的最大序列号syncedMaxSeqs[conversationID]:本地已同步的最大序列号len(storageMsgs):当前推送的消息数量
如果等式成立,说明消息是连续的;否则存在缺失,需要补偿同步。
第四步:处理缺失消息的逻辑
syncAndTriggerMsgs同步缺失消息
当检测到消息不连续时,主动拉取缺失的消息以保证完整性。
🔄 缺失消息补偿中的序列号状态恢复:
- 批量拉取策略:通过
pullMsgBySeqRange批量获取缺失的消息段 - MaxSeq状态恢复:
m.syncedMaxSeqs[conversationID] = maxSeq恢复会话最大序列号 - 消息完整性保证:确保
[begin, end]范围内的所有消息都被正确同步 - 分类触发机制:普通消息和通知消息分别触发不同的处理流程
- 状态一致性:补偿完成后内存状态与实际消息状态保持一致
// syncAndTriggerMsgs 同步缺失消息
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:788-866
func (m *MsgSyncer) syncAndTriggerMsgs(ctx context.Context, seqMap map[string][2]int64, syncMsgNum int64) error {
if len(seqMap) == 0 {
return nil
}
// 拉取缺失的消息
resp, err := m.pullMsgBySeqRange(ctx, seqMap, syncMsgNum)
if err != nil {
return errs.WrapMsg(err, "pullMsgBySeqRange failed")
}
// 检查并获取最新消息
messages := make(map[string]*sdkws.PullMsgs)
for conversationID, pullMsgs := range resp.Msgs {
messages[conversationID] = pullMsgs
}
for conversationID, pullMsgs := range resp.NotificationMsgs {
messages[conversationID] = pullMsgs
}
// 更新同步状态
for conversationID, seqRange := range seqMap {
if msgs, ok := messages[conversationID]; ok && len(msgs.Msgs) > 0 {
// 根据拉取到的消息更新最大同步序列号
maxSeq := seqRange[1] // 使用请求的结束序列号
m.syncedMaxSeqs[conversationID] = maxSeq
}
}
// 🔄 协程切换点:分别触发普通消息和通知消息处理
if len(resp.Msgs) > 0 {
_ = m.triggerConversation(ctx, resp.Msgs)
}
if len(resp.NotificationMsgs) > 0 {
_ = m.triggerNotification(ctx, resp.NotificationMsgs)
}
return nil
}
pullMsgBySeqRange拉取消息实现
向服务端发起消息拉取请求,获取指定序列号范围内的消息。
// pullMsgBySeqRange 拉取消息实现
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:1056-1091
func (m *MsgSyncer) pullMsgBySeqRange(ctx context.Context, seqMap map[string][2]int64, syncMsgNum int64) (resp *sdkws.PullMessageBySeqsResp, err error) {
// 构建请求参数
var seqRanges []*sdkws.SeqRange
for conversationID, seq := range seqMap {
seqRanges = append(seqRanges, &sdkws.SeqRange{
ConversationID: conversationID,
Begin: seq[0], // 起始序列号
End: seq[1], // 结束序列号
Num: syncMsgNum, // 拉取数量限制
})
}
// 发送拉取请求到服务端
req := &sdkws.PullMessageBySeqsReq{
UserID: m.loginUserID,
SeqRanges: seqRanges,
Order: sdkws.PullOrder_PullOrderAsc, // 升序拉取
}
// 等待服务端响应
err = m.longConnMgr.SendReqWaitResp(ctx, req, constant.PullMsgBySeqList, &resp)
if err != nil {
return nil, errs.WrapMsg(err, "SendReqWaitResp failed")
}
return resp, nil
}
第五步:triggerConversation消息事件处理详解
doPushMsg之后的triggerConversation处理流程
在doPushMsg处理完推送消息和连续性检查后,会调用triggerConversation方法来处理普通会话消息(单聊、群聊),这是消息同步协程向会话协程传递事件的关键步骤。
步骤1:triggerConversation事件封装
// triggerConversation 触发会话消息处理
// 路径:openim-sdk-core/internal/interaction/msg_sync.go:1159-1186
func (m *MsgSyncer) triggerConversation(ctx context.Context, msgs map[string]*sdkws.PullMsgs) error {
// 🔄 步骤1:基础验证
if len(msgs) == 0 {
log.ZWarn(ctx, "msgs is empty", errs.New("msgs is empty"))
return nil
}
// 🔄 步骤2:构造会话事件数据结构
// CmdNewMsgComeToConversation是会话协程的核心事件载体
cmd := sdk_struct.CmdNewMsgComeToConversation{
Msgs: msgs, // 按会话ID分组的消息列表
SyncFlag: constant.MsgSyncFlag, // 同步标志,区分实时消息和历史同步
}
log.ZDebug(ctx, "trigger conversation", "msgNum", len(msgs), "syncFlag", cmd.SyncFlag)
// 🔄 步骤3:关键协程切换点
// 从消息同步协程切换到会话协程,通过事件队列异步传递
return common.DispatchNewMessage(ctx, cmd, m.conversationEventQueue)
}
步骤2:DispatchNewMessage事件分发
// DispatchNewMessage 分发新消息事件到会话协程
// 路径:openim-sdk-core/pkg/common/trigger_channel.go:113-122
func DispatchNewMessage(ctx context.Context, msg sdk_struct.CmdNewMsgComeToConversation, queue *EventQueue) error {
// 🔄 步骤2.1:构造命令值对象
cmd := Cmd2Value{
Cmd: constant.CmdNewMsgCome, // 新消息到达命令标识
Value: msg, // 消息数据载体
Caller: GetCaller(2), // 调用者信息,用于调试
Ctx: ctx, // 上下文传递
}
// 🔄 步骤2.2:异步发送到会话事件队列
// 这里实现了协程间的完全解耦
return sendCmdToQueue(ctx, queue, cmd, 1*time.Second)
}
// sendCmdToQueue 安全发送命令到事件队列
func sendCmdToQueue(ctx context.Context, queue *EventQueue, value Cmd2Value, timeout time.Duration) error {
select {
case queue.queue <- &Event{Data: value}: // 成功发送到队列
return nil
case <-time.After(timeout): // 发送超时保护
return errs.New("send cmd to queue timeout")
case <-ctx.Done(): // 上下文取消
return ctx.Err()
}
}
步骤3:会话协程事件接收处理
// ConsumeConversationEventLoop 会话协程主循环
// 路径:openim-sdk-core/internal/conversation_msg/conversation_msg.go:1541-1576
func (c *Conversation) ConsumeConversationEventLoop(ctx context.Context) {
// 🔄 步骤3.1:会话协程持续监听事件队列
c.conversationEventQueue.ConsumeLoop(ctx,
// 事件处理函数
func(ctx context.Context, event *common.Event) {
// 🔄 步骤3.2:解析事件数据
cmd, ok := event.Data.(common.Cmd2Value)
if !ok {
log.ZWarn(ctx, "invalid event data in conversationEventQueue", nil)
return
}
log.ZInfo(cmd.Ctx, "recv cmd", "caller", cmd.Caller, "cmd", cmd.Cmd)
// 🔄 步骤3.3:调用Work方法处理具体事件
c.Work(cmd)
log.ZInfo(cmd.Ctx, "done cmd", "caller", cmd.Caller, "cmd", cmd.Cmd)
},
// 错误处理函数
func(msg string, fields ...any) {
log.ZError(ctx, msg, nil, fields...)
})
}
步骤4:Work方法事件路由
// Work 处理会话事件(会话协程的核心调度方法)
// 路径:openim-sdk-core/internal/conversation_msg/notification.go:63-97
func (c *Conversation) Work(c2v common.Cmd2Value) {
// 🔄 步骤4.1:根据命令类型进行路由分发
switch c2v.Cmd {
case constant.CmdNewMsgCome:
// 🔄 步骤4.2:新消息事件 - 最终执行目标
c.doMsgNew(c2v) // 这就是triggerConversation最终执行的方法!
default:
log.ZWarn(context.Background(), "unknown cmd", nil, "cmd", c2v.Cmd)
}
}
步骤5:doMsgNew最终执行方法
triggerConversation最终执行的就是doMsgNew方法!
// doMsgNew 处理新消息到达事件(triggerConversation的最终执行目标)
// 路径:openim-sdk-core/internal/conversation_msg/conversation_msg.go:220-694
// doMsgNew 处理新消息到达事件
// 这是OpenIM消息处理的核心方法,负责处理从服务器接收到的新消息
//
// ========== 为什么需要这么复杂的处理逻辑? ==========
// 1. 多端同步:消息可能来自不同设备(手机、电脑、网页),需要保证数据一致性
// 2. 离线在线:用户可能在离线状态错过消息,上线后需要同步历史消息
// 3. 消息去重:网络重传、多设备登录等可能导致重复消息,需要识别并处理
// 4. 状态管理:消息状态(发送中、成功、失败)需要正确维护和更新
// 5. 会话维护:消息到达需要更新对应会话的最新消息、未读数等信息
// 6. UI通知:不同场景需要不同的UI通知方式(在线、离线、前台、后台)
//
// ========== 主要处理流程 ==========
// 1. 消息分类:区分自己发送的消息和他人发送的消息
// 2. 重复检测:检查消息是否已存在,避免重复插入
// 3. 状态更新:更新消息状态(如补充服务器返回的seq)
// 4. 会话同步:创建或更新相关会话信息
// 5. 数据持久化:批量插入消息和会话到本地数据库
// 6. 事件通知:触发UI更新和用户通知
func (c *Conversation) doMsgNew(c2v common.Cmd2Value) {
// ========== 初始化变量和数据结构 ==========
allMsg := c2v.Value.(sdk_struct.CmdNewMsgComeToConversation).Msgs // 获取所有新消息,按会话ID分组
ctx := c2v.Ctx // 获取上下文
var isTriggerUnReadCount bool // 是否触发未读计数变更事件
// ========== 为什么要用映射结构? ==========
// 1. 性能优化:按会话分组,减少数据库操作次数,提高批量插入效率
// 2. 事务安全:同一会话的消息可以在同一事务中处理,保证数据一致性
// 3. 内存优化:预分配容量,减少动态扩容的性能开销
insertMsg := make(map[string][]*model_struct.LocalChatLog, 10) // 需要插入的新消息
updateMsg := make(map[string][]*model_struct.LocalChatLog, 10) // 需要更新的消息(补充seq等信息)
var exceptionMsg []*model_struct.LocalChatLog // 异常消息列表(重复消息等)
var newMessages sdk_struct.NewMsgList // 需要通知UI的新消息列表
// ========== 为什么需要这些标志位? ==========
// 消息选项标志位决定了消息的处理方式,这是为了支持不同的业务场景:
// - isHistory: 区分实时消息和历史消息,历史消息不触发通知
// - isUnreadCount: 控制是否计入未读数,系统消息可能不计入未读
// - isConversationUpdate: 控制是否更新会话信息,避免过期消息更新会话
// - isNotPrivate: 区分普通消息和私聊消息,影响UI显示
// - isSenderConversationUpdate: 发送者是否需要更新会话,避免自己的消息重复更新
var isUnreadCount, isConversationUpdate, isHistory, isNotPrivate, isSenderConversationUpdate bool
// ========== 为什么需要这么多会话集合? ==========
// 1. conversationSet: 临时收集所有涉及的会话,用于后续比较
// 2. conversationChangedSet: 已存在但需要更新的会话(如最新消息、未读数)
// 3. newConversationSet: 全新的会话,需要首次创建
// 4. phConversationChangedSet: 隐藏会话中的变更会话,保持用户的隐藏设置
// 5. phNewConversationSet: 隐藏会话中的新会话,继承隐藏状态
// 这样分类是为了:
// - 减少数据库操作:批量插入和批量更新分别处理
// - 保持用户设置:隐藏会话的状态需要特殊处理
// - 触发正确事件:新会话和变更会话需要不同的UI通知
conversationChangedSet := make(map[string]*model_struct.LocalConversation) // 已变更的会话
newConversationSet := make(map[string]*model_struct.LocalConversation) // 新创建的会话
conversationSet := make(map[string]*model_struct.LocalConversation) // 所有涉及的会话
phConversationChangedSet := make(map[string]*model_struct.LocalConversation) // 隐藏会话中的变更会话
phNewConversationSet := make(map[string]*model_struct.LocalConversation) // 隐藏会话中的新会话
log.ZDebug(ctx, "message come here conversation ch", "conversation length", len(allMsg))
b := time.Now() // 记录处理开始时间,用于性能统计和问题排查
// ========== 为什么需要区分在线和离线消息? ==========
// 1. 通知策略不同:在线消息立即通知,离线消息可能需要推送通知
// 2. UI展示不同:在线消息可能有特殊的动画效果
// 3. 统计需求:区分实时消息和同步消息,用于数据分析
onlineMap := make(map[onlineMsgKey]struct{})
// ========== 按会话处理消息主循环 ==========
// 为什么按会话分组处理?
// 1. 数据库优化:同一会话的消息可以批量插入,减少IO操作
// 2. 事务完整性:同一会话的消息在同一事务中处理,保证数据一致性
// 3. 会话状态:每个会话的最新消息、未读数等需要统一计算
// 4. 内存管理:按会话处理可以及时释放内存,避免大批量消息占用过多内存
for conversationID, msgs := range allMsg {
log.ZDebug(ctx, "parse message in one conversation", "conversationID",
conversationID, "message length", len(msgs.Msgs))
// ========== 为什么要分类存储消息? ==========
// 1. insertMessage: 异常消息(重复、删除等),需要特殊处理
// 2. selfInsertMessage: 自己发送的消息,需要填充自己的头像昵称
// 3. othersInsertMessage: 他人发送的消息,需要获取发送者信息
// 4. updateMessage: 已存在的消息,只需要更新部分字段(如seq)
// 这样分类的原因:
// - 性能优化:不同类型的消息需要不同的处理逻辑
// - 批量操作:相同类型的消息可以批量处理
// - 用户体验:自己和他人的消息显示方式不同
var insertMessage, selfInsertMessage, othersInsertMessage []*model_struct.LocalChatLog
var updateMessage []*model_struct.LocalChatLog // 需要更新的消息(通常是补充seq信息)
// ========== 处理会话中的每条消息 ==========
for _, v := range msgs.Msgs {
log.ZDebug(ctx, "parse message ", "conversationID", conversationID, "msg", v)
// ========== 解析消息选项标志位 ==========
// 为什么需要这些标志位?每个标志位解决什么问题?
// isHistory: 区分实时消息和历史消息
// 为什么需要?用户上线后同步历史消息时,不应该触发新消息通知
// 场景:用户离线8小时后上线,同步100条历史消息,不应该弹出100个通知
isHistory = utils.GetSwitchFromOptions(v.Options, constant.IsHistory)
// isUnreadCount: 控制是否计入未读数
// 为什么需要?某些系统消息(如入群通知)可能不希望影响未读数
// 场景:管理员批量拉人入群,不应该让每个人都有大量未读数
isUnreadCount = utils.GetSwitchFromOptions(v.Options, constant.IsUnreadCount)
// isConversationUpdate: 控制是否更新会话信息
// 为什么需要?避免旧消息覆盖会话的最新信息
// 场景:同步历史消息时,不应该让旧消息成为会话的"最新消息"
isConversationUpdate = utils.GetSwitchFromOptions(v.Options, constant.IsConversationUpdate)
// isNotPrivate: 区分普通消息和私聊消息
// 为什么需要?私聊模式下的消息有特殊的显示和处理逻辑
// 场景:阅后即焚、私密会话等功能需要特殊标记
isNotPrivate = utils.GetSwitchFromOptions(v.Options, constant.IsNotPrivate)
// isSenderConversationUpdate: 发送者是否更新会话
// 为什么需要?发送者和接收者的会话更新逻辑可能不同
// 场景:发送者发送消息后不应该重复更新自己的会话信息
isSenderConversationUpdate = utils.GetSwitchFromOptions(v.Options, constant.IsSenderConversationUpdate)
// ========== 消息结构转换和数据准备 ==========
msg := &sdk_struct.MsgStruct{}
copier.Copy(msg, v) // 复制基本字段
msg.Content = string(v.Content) // 转换消息内容为字符串
// 解析附加信息(如私聊标识、群组信息等)
var attachedInfo sdk_struct.AttachedInfoElem
_ = utils.JsonStringToStruct(v.AttachedInfo, &attachedInfo)
msg.AttachedInfoElem = &attachedInfo
// ========== 处理已删除消息 ==========
// 当消息已被云端标记删除时,直接插入本地,不更新会话和消息状态
if msg.Status == constant.MsgStatusHasDeleted {
dbMessage := MsgStructToLocalChatLog(msg)
c.handleExceptionMessages(ctx, nil, dbMessage) // 处理异常消息
exceptionMsg = append(exceptionMsg, dbMessage)
insertMessage = append(insertMessage, dbMessage)
continue
}
// 设置消息状态为发送成功(从服务器接收的消息都认为是成功的)
msg.Status = constant.MsgStatusSendSuccess
// ========== 根据消息类型解析消息内容 ==========
// 不同类型的消息需要不同的解析方式(文本、图片、文件等)
err := msgHandleByContentType(msg)
if err != nil {
log.ZError(ctx, "Parsing data error:", err, "type: ", msg.ContentType, "msg", msg)
continue
}
// ========== 设置私聊标识 ==========
if !isNotPrivate {
msg.AttachedInfoElem.IsPrivateChat = true
}
// ========== 验证会话ID ==========
if conversationID == "" {
log.ZError(ctx, "conversationID is empty", errors.New("conversationID is empty"), "msg", msg)
continue
}
// ========== 标记在线消息 ==========
if !isHistory {
// 记录在线消息,用于后续区分在线和离线消息的通知方式
onlineMap[onlineMsgKey{ClientMsgID: v.ClientMsgID, ServerMsgID: v.ServerMsgID}] = struct{}{}
newMessages = append(newMessages, msg)
}
log.ZDebug(ctx, "decode message", "msg", msg)
// ========== 根据发送者分类处理消息 ==========
if v.SendID == c.loginUserID {
// ========== 处理自己发送的消息 ==========
// 为什么自己发送的消息也会收到?
// 1. 多设备同步:在手机发送的消息需要同步到电脑和网页
// 2. 状态确认:客户端发送消息后,服务器返回确认信息(seq、时间戳等)
// 3. 历史同步:重装应用或切换设备时,需要同步自己的历史消息
// 检查消息是否已存在于本地数据库
existingMsg, err := c.db.GetMessage(ctx, conversationID, msg.ClientMsgID)
if err == nil {
// ========== 情况1:消息已存在,通常是状态更新 ==========
log.ZInfo(ctx, "have message", "msg", msg)
if existingMsg.Seq == 0 {
// 为什么本地消息的seq会是0?
// 发送消息的流程:客户端先本地插入消息(seq=0)-> 发送到服务器 -> 服务器返回seq
// 这里收到的就是服务器返回的确认信息,需要更新本地消息的seq
if !isConversationUpdate {
// 为什么要标记为已过滤?
// 某些场景下(如历史同步),不希望触发会话更新,但仍需要更新消息状态
msg.Status = constant.MsgStatusFiltered
}
updateMessage = append(updateMessage, MsgStructToLocalChatLog(msg))
} else {
// 为什么消息已有seq还会再次收到?
// 1. 网络重传:网络不稳定导致重复接收
// 2. 多端登录:多个设备同时在线,重复处理
// 3. 服务器重复推送:服务器异常导致重复发送
// 这种情况属于异常,需要特殊处理避免数据冲突
dbMessage := MsgStructToLocalChatLog(msg)
c.handleExceptionMessages(ctx, existingMsg, dbMessage)
insertMessage = append(insertMessage, dbMessage)
exceptionMsg = append(exceptionMsg, dbMessage)
}
} else {
// ========== 情况2:消息不存在于本地,这是同步消息 ==========
// 为什么自己发送的消息本地会不存在?
// 1. 其他设备发送:在其他设备(手机、网页)发送的消息
// 2. API发送:通过服务器API发送的消息
// 3. 历史同步:重装应用后同步的历史消息
log.ZInfo(ctx, "sync message", "msg", msg)
// 创建会话信息
lc := model_struct.LocalConversation{
ConversationType: v.SessionType,
LatestMsg: utils.StructToJsonString(msg),
LatestMsgSendTime: msg.SendTime,
ConversationID: conversationID,
}
// 根据会话类型设置相应字段
switch v.SessionType {
case constant.SingleChatType:
lc.UserID = v.RecvID // 单聊时设置对方用户ID
case constant.WriteGroupChatType, constant.ReadGroupChatType:
lc.GroupID = v.GroupID // 群聊时设置群组ID
}
// 根据标志位决定是否更新会话和添加到新消息列表
if isConversationUpdate {
if isSenderConversationUpdate {
log.ZDebug(ctx, "updateConversation msg", "message", v, "conversation", lc)
c.updateConversation(&lc, conversationSet)
}
newMessages = append(newMessages, msg)
}
// 历史消息直接插入,不触发通知
if isHistory {
selfInsertMessage = append(selfInsertMessage, MsgStructToLocalChatLog(msg))
}
}
} else {
// ========== 处理他人发送的消息 ==========
// 他人发送的消息处理逻辑相对简单,主要关注:
// 1. 消息去重:避免重复插入相同消息
// 2. 会话创建:为新的聊天对象创建会话
// 3. 未读计数:正确统计未读消息数量
// 4. 状态更新:更新会话的最新消息信息
if existingMsg, err := c.db.GetMessage(ctx, conversationID, msg.ClientMsgID); err != nil {
// ========== 新消息处理:创建会话信息 ==========
// 为什么要创建会话信息?
// 1. 会话列表显示:用户需要在会话列表中看到这个聊天
// 2. 最新消息:会话列表需要显示最新的一条消息
// 3. 时间排序:会话列表按最后活跃时间排序
// 4. 未读统计:统计该会话的未读消息数量
lc := model_struct.LocalConversation{
ConversationType: v.SessionType,
LatestMsg: utils.StructToJsonString(msg),
LatestMsgSendTime: msg.SendTime,
ConversationID: conversationID,
}
// ========== 根据会话类型设置会话显示信息 ==========
// 为什么需要按类型设置不同信息?
// 1. 单聊:显示对方的头像和昵称
// 2. 群聊:显示群组的头像和群名
// 3. 通知:显示系统或机器人信息
switch v.SessionType {
case constant.SingleChatType:
// 单聊:设置发送者信息作为会话显示信息
// 为什么用发送者信息?单聊中对方就是发送者
lc.UserID = v.SendID
lc.ShowName = msg.SenderNickname
lc.FaceURL = msg.SenderFaceURL
case constant.WriteGroupChatType, constant.ReadGroupChatType:
// 群聊:设置群组ID,稍后会批量获取群组信息
lc.GroupID = v.GroupID
case constant.NotificationChatType:
// 通知类型:通常是系统消息或机器人消息
lc.UserID = v.SendID
}
// ========== 处理未读计数 ==========
// 为什么未读计数这么重要且复杂?
// 1. 用户体验:未读数是用户最关心的信息之一
// 2. 性能影响:错误的未读数会导致不必要的UI更新
// 3. 多端同步:多个设备的未读数需要保持一致
// 4. 业务逻辑:某些消息不应计入未读数(如系统通知)
if isUnreadCount {
// 为什么要检查是否为新消息?
// 1. 避免重复计数:同一条消息不应该被重复计入未读数
// 2. 历史消息:同步历史消息时不应该增加未读数
// 3. 序列号判断:通过比较序列号确定消息的新旧程度
// maxSeqRecorder记录的是用户已知的最大序列号
if c.maxSeqRecorder.IsNewMsg(conversationID, msg.Seq) {
isTriggerUnReadCount = true // 标记需要触发未读计数变更事件
lc.UnreadCount = 1 // 设置未读数为1(单条新消息)
c.maxSeqRecorder.Incr(conversationID, 1) // 更新序列号记录器
// 为什么用Incr而不是直接设置?
// 1. 并发安全:多条消息可能同时处理
// 2. 增量更新:只增加新消息的数量
// 3. 状态一致:确保记录器状态与实际处理保持同步
}
}
// ========== 更新会话信息 ==========
if isConversationUpdate {
c.updateConversation(&lc, conversationSet)
newMessages = append(newMessages, msg)
}
// ========== 处理历史消息 ==========
if isHistory {
othersInsertMessage = append(othersInsertMessage, MsgStructToLocalChatLog(msg))
}
} else {
// 消息已存在,这是重复消息,标记为异常
dbMessage := MsgStructToLocalChatLog(msg)
c.handleExceptionMessages(ctx, existingMsg, dbMessage)
insertMessage = append(insertMessage, dbMessage)
exceptionMsg = append(exceptionMsg, dbMessage)
}
}
}
// ========== 整理当前会话的消息 ==========
// 合并所有需要插入的消息,并处理头像昵称信息
insertMsg[conversationID] = append(insertMessage, c.faceURLAndNicknameHandle(ctx, selfInsertMessage, othersInsertMessage, conversationID)...)
// 收集需要更新的消息
if len(updateMessage) > 0 {
updateMsg[conversationID] = updateMessage
}
}
// ========== 数据库操作同步锁 ==========
// 为什么需要这个锁?
// 1. 数据一致性:防止并发处理导致的数据冲突
// 2. 事务完整性:确保消息和会话的更新是原子操作
// 3. 状态同步:避免会话状态计算错误(如未读数、最新消息)
// 4. 去重保证:防止重复消息被并发插入
//
// 为什么是全局锁而不是细粒度锁?
// 1. 简单可靠:全局锁虽然性能较差,但能保证绝对的数据一致性
// 2. 跨会话操作:某些操作可能涉及多个会话,细粒度锁容易死锁
// 3. 复杂性权衡:细粒度锁需要复杂的锁排序和死锁检测机制
//
// TODO: 锁粒度优化方案
// 1. 会话级别锁:按conversationID进行锁分片
// 2. 读写分离:读操作使用读锁,写操作使用写锁
// 3. 无锁设计:使用CAS操作和版本号机制
c.conversationSyncMutex.Lock()
defer c.conversationSyncMutex.Unlock()
// ========== 获取本地所有会话并进行差异比较 ==========
list, err := c.db.GetAllConversationListDB(ctx)
if err != nil {
log.ZError(ctx, "GetAllConversationListDB", err)
}
// 将会话列表转换为映射,便于查找和比较
m := make(map[string]*model_struct.LocalConversation)
listToMap(list, m)
log.ZDebug(ctx, "listToMap: ", "local conversation", list, "generated c map",
string(stringutil.StructToJsonBytes(conversationSet)))
// 比较本地会话和新生成的会话,区分出变更和新增的会话
c.diff(ctx, m, conversationSet, conversationChangedSet, newConversationSet)
log.ZInfo(ctx, "trigger map is :", "newConversations", string(stringutil.StructToJsonBytes(newConversationSet)),
"changedConversations", string(stringutil.StructToJsonBytes(conversationChangedSet)))
// ========== 批量更新消息(主要是补充seq信息) ==========
if err := c.batchUpdateMessageList(ctx, updateMsg); err != nil {
log.ZError(ctx, "sync seq normal message err :", err)
}
// ========== 批量插入新消息 ==========
_ = c.batchInsertMessageList(ctx, insertMsg)
// ========== 处理隐藏会话列表 ==========
// 为什么需要隐藏会话机制?
// 1. 用户体验:用户可以隐藏不常用的会话,保持界面清洁
// 2. 设置保持:隐藏的会话仍需保留用户的个性化设置(置顶、免打扰等)
// 3. 消息接收:隐藏不等于删除,仍需要接收和处理消息
// 4. 恢复机制:用户发送消息或收到新消息时,会话会自动显示
//
// 隐藏会话的生命周期:
// 正常会话 -> 用户隐藏 -> 隐藏会话 -> 有新消息 -> 显示会话
hList, _ := c.db.GetHiddenConversationList(ctx)
for _, v := range hList {
if nc, ok := newConversationSet[v.ConversationID]; ok {
// ========== 隐藏会话复活处理 ==========
// 为什么要"复活"隐藏会话?
// 当隐藏的会话有新消息时,应该重新显示给用户
// 但需要保持用户之前的所有设置
// 将新会话移到隐藏会话变更集合中,并继承隐藏会话的设置
phConversationChangedSet[v.ConversationID] = nc
// ========== 继承用户设置 ==========
// 为什么要继承这些设置?保持用户的个性化配置
nc.RecvMsgOpt = v.RecvMsgOpt // 继承接收消息选项(免打扰等)
nc.GroupAtType = v.GroupAtType // 继承群组@类型(是否被@过)
nc.IsPinned = v.IsPinned // 继承置顶状态
nc.IsPrivateChat = v.IsPrivateChat // 继承私聊状态(阅后即焚等)
if nc.IsPrivateChat {
nc.BurnDuration = v.BurnDuration // 继承阅后即焚时长
}
if v.UnreadCount != 0 {
// 为什么要继承未读数?
// 隐藏期间可能积累了未读消息,这些应该保留
nc.UnreadCount = v.UnreadCount // 继承未读数
}
nc.IsNotInGroup = v.IsNotInGroup // 继承是否在群组状态
nc.AttachedInfo = v.AttachedInfo // 继承附加信息
nc.Ex = v.Ex // 继承扩展字段
nc.IsMsgDestruct = v.IsMsgDestruct // 继承消息销毁设置
nc.MsgDestructTime = v.MsgDestructTime // 继承消息销毁时间
}
}
// ========== 分离真正的新会话 ==========
// 为什么要分离?
// 1. 数据库操作优化:新会话和恢复会话使用不同的SQL操作
// 2. 事件通知:新会话和恢复会话需要触发不同的UI事件
// 3. 状态管理:区分首次创建和重新激活,便于统计和分析
for k, v := range newConversationSet {
if _, ok := phConversationChangedSet[v.ConversationID]; !ok {
phNewConversationSet[k] = v
}
}
// ========== 批量更新会话信息 ==========
if err := c.db.BatchUpdateConversationList(ctx, append(mapConversationToList(conversationChangedSet), mapConversationToList(phConversationChangedSet)...)); err != nil {
log.ZError(ctx, "insert changed conversation err :", err)
}
// ========== 批量插入新会话 ==========
if err := c.db.BatchInsertConversationList(ctx, mapConversationToList(phNewConversationSet)); err != nil {
log.ZError(ctx, "insert new conversation err:", err)
}
log.ZDebug(ctx, "before trigger msg", "cost time", time.Since(b).Seconds(), "len", len(allMsg))
// ========== 触发新消息事件 ==========
c.newMessage(ctx, newMessages, conversationChangedSet, newConversationSet, onlineMap)
// ========== 触发会话变更事件 ==========
if len(newConversationSet) > 0 {
// 触发新会话事件
c.doUpdateConversation(common.Cmd2Value{Value: common.UpdateConNode{Action: constant.NewConDirect, Args: utils.StructToJsonString(mapConversationToList(newConversationSet))}})
}
if len(conversationChangedSet) > 0 {
// 触发会话变更事件
c.doUpdateConversation(common.Cmd2Value{Value: common.UpdateConNode{Action: constant.ConChangeDirect, Args: utils.StructToJsonString(mapConversationToList(conversationChangedSet))}})
}
// ========== 触发未读计数变更事件 ==========
if isTriggerUnReadCount {
c.doUpdateConversation(common.Cmd2Value{Value: common.UpdateConNode{Action: constant.TotalUnreadMessageChanged, Args: ""}})
}
// ========== 处理输入状态消息 ==========
for _, msgs := range allMsg {
for _, msg := range msgs.Msgs {
if msg.ContentType == constant.Typing {
c.typing.onNewMsg(ctx, msg) // 处理正在输入状态
}
}
}
// ========== 记录异常消息 ==========
for _, v := range exceptionMsg {
log.ZWarn(ctx, "exceptionMsg show: ", nil, "msg", *v)
}
log.ZDebug(ctx, "insert msg", "duration", fmt.Sprintf("%dms", time.Since(b)), "len", len(allMsg))
}
## 第五步:doMsgNew方法完整逻辑解析
### 5.1 doMsgNew方法整体架构
`doMsgNew`是OpenIM消息处理的核心方法,负责处理从服务器接收到的新消息。这个方法设计复杂但逻辑清晰,主要解决以下关键问题:
1. **多端同步**:确保消息在不同设备间正确同步
2. **消息去重**:防止重复消息被多次处理
3. **会话管理**:创建和更新相关会话信息
4. **状态维护**:正确维护消息和会话的各种状态
5. **事件通知**:触发相应的UI更新事件
### 5.2 消息处理的核心数据结构
```go
// 消息分类存储映射
insertMsg := make(map[string][]*model_struct.LocalChatLog, 10) // 按会话ID分组的新消息
updateMsg := make(map[string][]*model_struct.LocalChatLog, 10) // 需要更新的消息(补充seq等)
var exceptionMsg []*model_struct.LocalChatLog // 异常消息(重复、删除等)
var newMessages sdk_struct.NewMsgList // 需要通知UI的新消息
// 会话状态集合
conversationChangedSet := make(map[string]*model_struct.LocalConversation) // 已变更的会话
newConversationSet := make(map[string]*model_struct.LocalConversation) // 新创建的会话
conversationSet := make(map[string]*model_struct.LocalConversation) // 所有涉及的会话
phConversationChangedSet := make(map[string]*model_struct.LocalConversation) // 隐藏会话中的变更
phNewConversationSet := make(map[string]*model_struct.LocalConversation) // 隐藏会话中的新增
// 在线消息标识
onlineMap := make(map[onlineMsgKey]struct{}) // 标识哪些是在线消息
5.3 消息选项标志位解析
每条消息都携带选项标志位,控制消息的处理方式:
// 消息选项解析
isHistory := utils.GetSwitchFromOptions(v.Options, constant.IsHistory)
isUnreadCount := utils.GetSwitchFromOptions(v.Options, constant.IsUnreadCount)
isConversationUpdate := utils.GetSwitchFromOptions(v.Options, constant.IsConversationUpdate)
isNotPrivate := utils.GetSwitchFromOptions(v.Options, constant.IsNotPrivate)
isSenderConversationUpdate := utils.GetSwitchFromOptions(v.Options, constant.IsSenderConversationUpdate)
标志位说明:
isHistory: 区分实时消息和历史消息,历史消息不触发通知isUnreadCount: 控制是否计入未读数,某些系统消息不计入isConversationUpdate: 控制是否更新会话信息,避免旧消息更新会话isNotPrivate: 区分普通消息和私聊消息isSenderConversationUpdate: 发送者是否需要更新会话
5.4 消息处理的双分支逻辑
5.4.1 处理自己发送的消息
if v.SendID == c.loginUserID {
// 自己发送的消息有两种情况:
// 1. 多设备同步:其他设备发送的消息同步到当前设备
// 2. 状态确认:发送后服务器返回的确认信息(补充seq等)
existingMsg, err := c.db.GetMessage(ctx, conversationID, msg.ClientMsgID)
if err == nil {
// 消息已存在,通常是状态更新
if existingMsg.Seq == 0 {
// 本地消息seq为0,这是服务器返回的确认信息
updateMessage = append(updateMessage, MsgStructToLocalChatLog(msg))
} else {
// 消息已有seq,这是重复消息,标记为异常
dbMessage := MsgStructToLocalChatLog(msg)
c.handleExceptionMessages(ctx, existingMsg, dbMessage)
exceptionMsg = append(exceptionMsg, dbMessage)
}
} else {
// 消息不存在,这是来自其他设备的同步消息
// 创建会话信息并根据标志位决定是否更新
}
}
5.4.2 处理他人发送的消息
else {
// 他人发送的消息处理相对简单
if existingMsg, err := c.db.GetMessage(ctx, conversationID, msg.ClientMsgID); err != nil {
// 新消息:创建会话信息
lc := model_struct.LocalConversation{
ConversationType: v.SessionType,
LatestMsg: utils.StructToJsonString(msg),
LatestMsgSendTime: msg.SendTime,
ConversationID: conversationID,
}
// 处理未读计数
if isUnreadCount && c.maxSeqRecorder.IsNewMsg(conversationID, msg.Seq) {
isTriggerUnReadCount = true
lc.UnreadCount = 1
c.maxSeqRecorder.Incr(conversationID, 1)
}
// 更新会话信息
if isConversationUpdate {
c.updateConversation(&lc, conversationSet)
newMessages = append(newMessages, msg)
}
} else {
// 重复消息,标记为异常
dbMessage := MsgStructToLocalChatLog(msg)
c.handleExceptionMessages(ctx, existingMsg, dbMessage)
exceptionMsg = append(exceptionMsg, dbMessage)
}
}
5.5 消息和会话属性变化详解
5.5.1 消息属性的初始化和更新
当新消息到达时,消息对象的关键属性处理:
// 消息基本属性设置
msg.Status = constant.MsgStatusSendSuccess // 从服务器接收的消息都标记为成功
msg.IsRead = false // 新消息初始为未读状态
msg.Seq = v.Seq // 服务器分配的序列号
msg.ServerMsgID = v.ServerMsgID // 服务器生成的消息ID
msg.SendTime = v.SendTime // 实际发送时间
msg.CreateTime = v.CreateTime // 消息创建时间
// 解析消息内容和附加信息
err := msgHandleByContentType(msg) // 根据消息类型解析内容
var attachedInfo sdk_struct.AttachedInfoElem
_ = utils.JsonStringToStruct(v.AttachedInfo, &attachedInfo)
msg.AttachedInfoElem = &attachedInfo // 设置附加信息
5.5.2 会话属性的创建和更新
// 会话信息创建
lc := model_struct.LocalConversation{
ConversationID: conversationID, // 会话唯一标识
ConversationType: v.SessionType, // 会话类型(单聊/群聊)
LatestMsg: utils.StructToJsonString(msg), // 最新消息JSON
LatestMsgSendTime: msg.SendTime, // 最新消息时间
UnreadCount: 0, // 初始未读数
MaxSeq: msg.Seq, // 最大序列号
MinSeq: msg.Seq, // 最小序列号(首条消息时)
}
// 根据会话类型设置特定属性
switch v.SessionType {
case constant.SingleChatType:
lc.UserID = v.SendID // 单聊:设置对方用户ID
lc.ShowName = msg.SenderNickname // 显示对方昵称
lc.FaceURL = msg.SenderFaceURL // 显示对方头像
case constant.ReadGroupChatType:
lc.GroupID = v.GroupID // 群聊:设置群组ID
}
5.5.3 MaxSeq和ReadSeq状态管理
// MaxSeq状态管理
if c.maxSeqRecorder.IsNewMsg(conversationID, msg.Seq) {
// 这是一条新消息(序列号大于已知最大值)
c.maxSeqRecorder.Incr(conversationID, 1) // 更新内存中的最大序列号
// 同时更新会话的MaxSeq
if conversation != nil {
conversation.MaxSeq = msg.Seq
}
}
// ReadSeq的处理在后续的已读回执中进行
// 用户阅读消息后,会调用markConversationMessageAsRead方法更新ReadSeq
5.5.4 未读数量计算机制
// 未读数量的精确计算
if isUnreadCount && c.maxSeqRecorder.IsNewMsg(conversationID, msg.Seq) {
// 只有满足以下条件的消息才计入未读数:
// 1. 消息选项允许计入未读数(isUnreadCount = true)
// 2. 消息序列号大于已知最大序列号(是新消息)
// 3. 不是历史同步消息(isHistory = false)
isTriggerUnReadCount = true // 标记需要触发未读数变更事件
lc.UnreadCount = 1 // 新会话未读数为1
// 如果是已存在会话,累加未读数
if existingConversation != nil {
existingConversation.UnreadCount += 1
}
// 更新内存中的序列号记录
c.maxSeqRecorder.Incr(conversationID, 1)
}
5.6 数据库操作和事务性
5.6.1 同步锁保护
// 全局同步锁确保数据一致性
c.conversationSyncMutex.Lock()
defer c.conversationSyncMutex.Unlock()
// 锁的作用:
// 1. 防止并发处理导致的数据冲突
// 2. 确保消息和会话的更新是原子操作
// 3. 避免重复消息被并发插入
// 4. 保证状态计算的准确性(如未读数、最新消息)
5.6.2 批量数据库操作
// 批量更新消息(主要是补充seq信息)
if err := c.batchUpdateMessageList(ctx, updateMsg); err != nil {
log.ZError(ctx, "sync seq normal message err:", err)
}
// 批量插入新消息
_ = c.batchInsertMessageList(ctx, insertMsg)
// 批量更新会话信息
conversationsToUpdate := append(
mapConversationToList(conversationChangedSet),
mapConversationToList(phConversationChangedSet)...,
)
if err := c.db.BatchUpdateConversationList(ctx, conversationsToUpdate); err != nil {
log.ZError(ctx, "update conversations err:", err)
}
// 批量插入新会话
if err := c.db.BatchInsertConversationList(ctx, mapConversationToList(phNewConversationSet)); err != nil {
log.ZError(ctx, "insert new conversations err:", err)
}
5.7 事件通知机制
5.7.1 消息事件通知
// 触发新消息事件
c.newMessage(ctx, newMessages, conversationChangedSet, newConversationSet, onlineMap)
// newMessage方法内部根据应用状态选择通知方式:
if c.GetBackground() {
// 后台模式:检查接收设置后触发离线消息通知
for _, w := range newMessagesList {
if shouldNotify(w) {
c.msgListener().OnRecvOfflineNewMessage(utils.StructToJsonString(w))
}
}
} else {
// 前台模式:区分在线消息和普通消息
for _, w := range newMessagesList {
if isOnlineMsg(w) {
c.msgListener().OnRecvOnlineOnlyMessage(utils.StructToJsonString(w))
} else {
c.msgListener().OnRecvNewMessage(utils.StructToJsonString(w))
}
}
}
5.7.2 会话事件通知
// 触发新会话事件
if len(newConversationSet) > 0 {
c.doUpdateConversation(common.Cmd2Value{
Value: common.UpdateConNode{
Action: constant.NewConDirect,
Args: utils.StructToJsonString(mapConversationToList(newConversationSet)),
},
})
}
// 触发会话变更事件
if len(conversationChangedSet) > 0 {
c.doUpdateConversation(common.Cmd2Value{
Value: common.UpdateConNode{
Action: constant.ConChangeDirect,
Args: utils.StructToJsonString(mapConversationToList(conversationChangedSet)),
},
})
}
// 触发未读计数变更事件
if isTriggerUnReadCount {
c.doUpdateConversation(common.Cmd2Value{
Value: common.UpdateConNode{
Action: constant.TotalUnreadMessageChanged,
Args: "",
},
})
}
5.8 特殊功能处理
5.8.1 隐藏会话复活机制
// 处理隐藏会话列表
hList, _ := c.db.GetHiddenConversationList(ctx)
for _, v := range hList {
if nc, ok := newConversationSet[v.ConversationID]; ok {
// 隐藏会话有新消息时,需要重新显示
phConversationChangedSet[v.ConversationID] = nc
// 继承用户的所有个性化设置
nc.RecvMsgOpt = v.RecvMsgOpt // 接收消息选项
nc.IsPinned = v.IsPinned // 置顶状态
nc.IsPrivateChat = v.IsPrivateChat // 私聊设置
nc.BurnDuration = v.BurnDuration // 阅后即焚时长
nc.UnreadCount = v.UnreadCount // 继承未读数
// ... 其他设置
}
}
5.8.2 输入状态处理
// 处理正在输入状态消息
for _, msgs := range allMsg {
for _, msg := range msgs.Msgs {
if msg.ContentType == constant.Typing {
c.typing.onNewMsg(ctx, msg) // 触发输入状态更新
}
}
}
5.9 性能优化和错误处理
5.9.1 批量操作优化
// 消息分类处理,提高批量操作效率
selfInsertMessage := []*model_struct.LocalChatLog{} // 自己发送的消息
othersInsertMessage := []*model_struct.LocalChatLog{} // 他人发送的消息
// 批量填充头像昵称信息
mergedMessages := c.faceURLAndNicknameHandle(ctx, selfInsertMessage, othersInsertMessage, conversationID)
insertMsg[conversationID] = mergedMessages
5.9.2 异常消息处理详解
// handleExceptionMessages 处理异常消息的插入到本地聊天记录
// 根据message_check.go源码实现的完整逻辑
//
// 该方法处理从服务器拉取到的各种异常消息情况,确保本地数据的完整性和一致性
// 识别并标记属于以下类别的消息:
//
// ========== 异常消息类型详解 ==========
//
// 【类型1】云端已删除的消息 (message.Status == constant.MsgStatusHasDeleted)
// - 含义:消息在云端被删除(通过撤回、管理员删除等操作)
// - 特征:从服务器拉取时状态已标记为已删除
// - 处理:作为占位消息保存,维护序列号连续性
//
// 子情况1.1: message.ClientMsgID == ""
// - 含义:这是系统推送的消息(如:系统通知、入群通知、踢人通知等)
// - 原因:系统消息通常由服务器直接生成,没有客户端消息ID
// - 特点:这类消息被删除后ClientMsgID会被清空
// - 处理:生成新的ClientMsgID,标记为序列号间隙占位符
//
// 子情况1.2: message.ClientMsgID != ""
// - 含义:普通用户消息被删除,但ClientMsgID保留
// - 原因:用户发送的消息被撤回或管理员删除
// - 处理:保留ClientMsgID,标记为已删除消息
//
// 【类型2】序列号间隙填充消息
// - 产生原因:服务器宕机、网络中断、长时间离线等导致序列号不连续
// - 特征:拉取消息时发现序列号中间有空缺
// - 处理:创建占位消息填补间隙,确保时间线完整
//
// 【类型3】ClientMsgID重复但序列号不同
// - 产生原因:客户端重发、服务端重复消费、网络重试等
// - 处理:标记为客户端重复消息
//
// 【类型4】ClientMsgID和序列号都重复
// - 产生原因:并发消息拉取、重复请求等
// - 处理:标记为序列号重复消息
//
// 参数:
// - ctx: 上下文
// - existingMessage: 本地已存在的消息(nil表示本地不存在)
// - message: 从服务器拉取的待处理消息
func (c *Conversation) handleExceptionMessages(ctx context.Context, existingMessage, message *model_struct.LocalChatLog) {
var prefix string
// ========== 根据异常类型确定前缀标识 ==========
if existingMessage == nil {
// ========== 情况1:本地不存在该消息 ==========
if message.Status == constant.MsgStatusHasDeleted {
// 云端消息已被删除(撤回、管理员删除等)
if message.ClientMsgID == "" {
// 子情况1.1:系统推送消息被删除后ClientMsgID被清空
// 例如:入群通知、踢人通知、系统公告等被管理员删除
// 系统消息由服务器生成,删除后ClientMsgID会被清空
// 需要重新生成ClientMsgID防止数据库主键冲突
message.ClientMsgID = utils.GetMsgID(c.loginUserID)
prefix = "[SYS_DEL_+" + utils.Int64ToString(message.Seq) + "]" // 系统消息删除占位符
} else {
// 子情况1.2:普通用户消息被删除,ClientMsgID保留
// 例如:用户撤回消息、管理员删除用户消息等
prefix = "[USER_DEL]" // 用户消息删除标记
}
} else {
// 正常消息,不应该进入异常处理流程
prefix = "[UNKNOWN]"
log.ZWarn(ctx, "Normal message should not be handled as exception", nil, "message", message)
}
} else {
// ========== 情况2:本地已存在相同ClientMsgID的消息 ==========
if existingMessage.Seq == message.Seq {
// ClientMsgID和序列号都重复 - 并发拉取导致
// 产生原因:多线程同时拉取消息、网络重试、重复请求等
prefix = "[CONCURRENT_DUP]" // 并发重复消息
} else {
// ClientMsgID重复但序列号不同 - 消息重发导致
// 产生原因:客户端网络重试、服务端重复消费、消息重发等
prefix = "[RESEND_DUP]" // 重发重复消息
}
}
// ========== 生成随机字符串函数 ==========
getRandomString := func(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, length)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
// ========== 统一处理异常消息 ==========
// 生成8位随机后缀确保ClientMsgID唯一性,避免数据库主键冲突
randomSuffix := "_" + getRandomString(8)
// 统一标记为已删除状态,前端可根据前缀判断显示方式
// - 系统删除消息:可显示为"系统消息已删除"
// - 用户删除消息:可显示为"消息已撤回"
// - 重复消息:可隐藏不显示或显示调试信息
message.Status = constant.MsgStatusHasDeleted
// 构造新的ClientMsgID:[异常类型前缀] + [原ClientMsgID] + [随机后缀]
// 这样既保留了原始信息又确保了唯一性,便于问题排查和调试
message.ClientMsgID = prefix + message.ClientMsgID + randomSuffix
}
// 使用示例和异常消息记录
func exampleUsage() {
// 记录所有异常消息供调试分析
for _, v := range exceptionMsg {
log.ZWarn(ctx, "exceptionMsg show:", nil, "msg", *v)
}
}
异常消息处理的核心设计原则:
- 数据完整性保证:即使是异常消息也要保存,确保消息序列号的连续性
- 唯一性保证:通过前缀+随机后缀避免ClientMsgID冲突
- 可追溯性:通过特殊前缀标识异常类型,便于问题排查
- 状态一致性:统一标记为已删除状态,前端可根据前缀决定显示方式
常见异常消息前缀含义:
[SYS_DEL_+seq]: 系统消息被删除的占位符[USER_DEL]: 用户消息被删除/撤回[CONCURRENT_DUP]: 并发拉取导致的重复消息[RESEND_DUP]: 重发导致的重复消息[UNKNOWN]: 未知类型的异常消息
5.10 doMsgNew完整流程图
graph TD
A[doMsgNew开始] --> B[初始化数据结构]
B --> C[遍历所有会话的消息]
C --> D{消息循环}
D --> E[解析消息选项标志位]
E --> F[消息结构转换]
F --> G[验证会话ID]
G --> H{检查消息状态}
H -->|已删除| I[处理已删除消息]
H -->|正常| J[设置消息状态为成功]
J --> K[解析消息内容]
K --> L{检查发送者}
L -->|自己发送| M[处理自己的消息]
L -->|他人发送| N[处理他人的消息]
M --> O{消息是否存在}
O -->|存在| P{检查Seq}
P -->|Seq=0| Q[更新消息状态]
P -->|Seq>0| R[标记重复消息]
O -->|不存在| S[同步消息处理]
N --> T{消息是否存在}
T -->|不存在| U[创建会话信息]
T -->|存在| V[标记重复消息]
U --> W{检查未读计数标志}
W -->|允许| X[计算未读数]
W -->|不允许| Y[跳过未读计数]
X --> Z{是否为新消息}
Z -->|是| AA[增加未读数]
Z -->|否| Y
AA --> BB[更新序列号记录器]
BB --> CC[更新会话信息]
Q --> DD[消息分类存储]
R --> DD
S --> DD
V --> DD
Y --> DD
CC --> DD
DD --> EE{是否处理完所有消息}
EE -->|否| D
EE -->|是| FF[获取数据库同步锁]
FF --> GG[获取本地会话列表]
GG --> HH[比较会话差异]
HH --> II[处理隐藏会话]
II --> JJ[批量更新消息]
JJ --> KK[批量插入消息]
KK --> LL[批量更新会话]
LL --> MM[批量插入新会话]
MM --> NN[触发新消息事件]
NN --> OO[触发会话变更事件]
OO --> PP[触发未读数变更事件]
PP --> QQ[处理输入状态消息]
QQ --> RR[记录异常消息]
RR --> SS[doMsgNew结束]
style A fill:#e1f5fe
style SS fill:#e8f5e8
style FF fill:#fff3e0
style NN fill:#f3e5f5
style X fill:#ffebee
style Z fill:#ffebee
第六步:云端PullMessageBySeqList实现详解
6.1 服务端消息拉取处理概述
当客户端发起PullMessageBySeqList请求时,服务端需要:
- 验证用户权限和会话访问权限
- 按序列号范围查询消息数据
- 处理消息内容的过滤和权限控制
- 返回分页结果和边界标识
6.2 PullMessageBySeqs核心实现
// PullMessageBySeqs 服务端消息拉取处理
// 路径:open-im-server/internal/rpc/msg/sync_msg.go
func (m *msgServer) PullMessageBySeqs(ctx context.Context, req *sdkws.PullMessageBySeqsReq) (*sdkws.PullMessageBySeqsResp, error) {
// 初始化响应结构,分别存储普通消息和通知消息
resp := &sdkws.PullMessageBySeqsResp{}
resp.Msgs = make(map[string]*sdkws.PullMsgs) // 普通消息映射
resp.NotificationMsgs = make(map[string]*sdkws.PullMsgs) // 通知消息映射
// 遍历所有请求的序列号范围,支持批量处理多个会话
for _, seq := range req.SeqRanges {
if !msgprocessor.IsNotification(seq.ConversationID) {
// 处理普通会话消息(单聊、群聊)
conversation, err := m.ConversationLocalCache.GetConversation(ctx, req.UserID, seq.ConversationID)
if err != nil {
log.ZError(ctx, "GetConversation error", err, "conversationID", seq.ConversationID)
continue
}
// 按序列号范围拉取消息,使用用户的MaxSeq进行权限控制
minSeq, maxSeq, msgs, err := m.MsgDatabase.GetMsgBySeqsRange(ctx, req.UserID, seq.ConversationID,
seq.Begin, seq.End, seq.Num, conversation.MaxSeq)
if err != nil {
log.ZWarn(ctx, "GetMsgBySeqsRange error", err)
continue
}
// 判断是否已到达拉取边界
var isEnd bool
switch req.Order {
case sdkws.PullOrder_PullOrderAsc: // 升序拉取
isEnd = maxSeq <= seq.End
case sdkws.PullOrder_PullOrderDesc: // 降序拉取
isEnd = seq.Begin <= minSeq
}
if len(msgs) > 0 {
resp.Msgs[seq.ConversationID] = &sdkws.PullMsgs{Msgs: msgs, IsEnd: isEnd}
}
} else {
// 处理通知类型消息
var seqs []int64
for i := seq.Begin; i <= seq.End; i++ {
seqs = append(seqs, i)
}
minSeq, maxSeq, notificationMsgs, err := m.MsgDatabase.GetMsgBySeqs(ctx, req.UserID, seq.ConversationID, seqs)
if err != nil {
log.ZWarn(ctx, "GetMsgBySeqs error", err)
continue
}
var isEnd bool
switch req.Order {
case sdkws.PullOrder_PullOrderAsc:
isEnd = maxSeq <= seq.End
case sdkws.PullOrder_PullOrderDesc:
isEnd = seq.Begin <= minSeq
}
if len(notificationMsgs) > 0 {
resp.NotificationMsgs[seq.ConversationID] = &sdkws.PullMsgs{Msgs: notificationMsgs, IsEnd: isEnd}
}
}
}
return resp, nil
}
6.3 数据库层getMsgBySeqs详细实现
// getMsgBySeqs 数据库层按序列号获取消息的核心实现
// 路径:open-im-server/pkg/common/storage/controller/msg.go:288-290
func (db *commonMsgDatabase) getMsgBySeqs(ctx context.Context, userID, conversationID string, seqs []int64) (totalMsgs []*sdkws.MsgData, err error) {
// 1. 获取会话的序列号边界
minSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)
if err != nil {
return nil, errs.WrapMsg(err, "GetMinSeq failed", "conversationID", conversationID)
}
maxSeq, err := db.seqConversation.GetMaxSeq(ctx, conversationID)
if err != nil {
return nil, errs.WrapMsg(err, "GetMaxSeq failed", "conversationID", conversationID)
}
// 2. 过滤有效的序列号(在边界范围内)
var validSeqs []int64
for _, seq := range seqs {
if seq >= minSeq && seq <= maxSeq {
validSeqs = append(validSeqs, seq)
}
}
if len(validSeqs) == 0 {
return nil, nil // 没有有效序列号
}
// 3. 从MongoDB获取消息数据
msgs, err := db.GetMessageBySeqs(ctx, conversationID, userID, validSeqs)
if err != nil {
return nil, errs.WrapMsg(err, "GetMessageBySeqs failed")
}
// 4. 处理消息内容过滤和权限控制
var filteredMsgs []*sdkws.MsgData
for _, msg := range msgs {
// 处理删除和撤回消息
if msg.Status == constant.MsgStatusHasDeleted {
// 已删除消息,根据用户权限决定是否显示
if userID == msg.SendID {
// 发送者可以看到删除标记
filteredMsgs = append(filteredMsgs, msg)
}
// 其他用户看不到已删除消息
continue
}
// 处理引用消息的权限
if msg.ContentType == constant.Quote {
db.handlerQuote(ctx, userID, conversationID, []*model.MsgInfoModel{msg})
}
filteredMsgs = append(filteredMsgs, msg)
}
return filteredMsgs, nil
}
6.4 序列号缓存管理
6.4.1 GetMinSeq实现
// GetMinSeq 获取会话最小序列号
// 优先从Redis缓存获取,缓存未命中时从MongoDB查询并更新缓存
func (db *seqConversationCache) GetMinSeq(ctx context.Context, conversationID string) (int64, error) {
// 1. 尝试从Redis缓存获取
cacheKey := fmt.Sprintf("seq:conversation:min:%s", conversationID)
minSeq, err := db.rdb.Get(ctx, cacheKey).Int64()
if err == nil {
return minSeq, nil // 缓存命中
}
// 2. 缓存未命中,从MongoDB查询
seqData, err := db.msgDatabase.GetMinMaxSeqMongo(ctx, conversationID)
if err != nil {
return 0, err
}
// 3. 更新Redis缓存,设置过期时间
err = db.rdb.Set(ctx, cacheKey, seqData.MinSeq, time.Hour*24).Err()
if err != nil {
log.ZWarn(ctx, "SetMinSeq cache failed", err, "conversationID", conversationID)
}
return seqData.MinSeq, nil
}
6.4.2 GetMaxSeq实现
// GetMaxSeq 获取会话最大序列号
// 实时性要求高,优先从Redis获取最新值
func (db *seqConversationCache) GetMaxSeq(ctx context.Context, conversationID string) (int64, error) {
// 1. 从Redis获取实时最大序列号
cacheKey := fmt.Sprintf("seq:conversation:max:%s", conversationID)
maxSeq, err := db.rdb.Get(ctx, cacheKey).Int64()
if err == nil {
return maxSeq, nil
}
// 2. Redis中不存在,从MongoDB查询并缓存
seqData, err := db.msgDatabase.GetMinMaxSeqMongo(ctx, conversationID)
if err != nil {
return 0, err
}
// 3. 设置缓存,MaxSeq缓存时间较短,保证实时性
err = db.rdb.Set(ctx, cacheKey, seqData.MaxSeq, time.Minute*30).Err()
if err != nil {
log.ZWarn(ctx, "SetMaxSeq cache failed", err)
}
return seqData.MaxSeq, nil
}
6.5 MongoDB消息查询实现
6.5.1 GetMessageBySeqs核心逻辑
// GetMessageBySeqs 从MongoDB按序列号批量查询消息
// 路径:open-im-server/pkg/common/storage/database/mongo/msg.go
func (m *MsgMongo) GetMessageBySeqs(ctx context.Context, conversationID string, userID string, seqs []int64) ([]*sdkws.MsgData, error) {
// 1. 计算文档范围,MongoDB中消息按固定数量分片存储
if len(seqs) == 0 {
return nil, nil
}
// 2. 按文档分组序列号
docSeqsMap := m.calculateDocumentRanges(conversationID, seqs)
var allMsgs []*sdkws.MsgData
// 3. 逐个文档查询消息
for docID, docSeqs := range docSeqsMap {
msgs, err := m.findMsgsByDocumentAndSeqs(ctx, docID, docSeqs)
if err != nil {
log.ZError(ctx, "findMsgsByDocumentAndSeqs failed", err, "docID", docID)
continue
}
allMsgs = append(allMsgs, msgs...)
}
// 4. 按序列号排序
sort.Slice(allMsgs, func(i, j int) bool {
return allMsgs[i].Seq < allMsgs[j].Seq
})
return allMsgs, nil
}
// calculateDocumentRanges 计算序列号对应的文档范围
func (m *MsgMongo) calculateDocumentRanges(conversationID string, seqs []int64) map[string][]int64 {
docSeqsMap := make(map[string][]int64)
for _, seq := range seqs {
// 计算序列号对应的文档ID
// MongoDB中每个文档存储固定数量的消息(如10000条)
docIndex := (seq - 1) / 10000 // 文档索引
docID := fmt.Sprintf("%s:%d", conversationID, docIndex)
docSeqsMap[docID] = append(docSeqsMap[docID], seq)
}
return docSeqsMap
}
6.5.2 FindSeqs核心查询
// findMsgsByDocumentAndSeqs 在指定文档中查找特定序列号的消息
func (m *MsgMongo) findMsgsByDocumentAndSeqs(ctx context.Context, docID string, seqs []int64) ([]*sdkws.MsgData, error) {
// 1. 构建MongoDB查询条件
filter := bson.M{
"_id": docID, // 文档ID
"msgs": bson.M{
"$elemMatch": bson.M{
"msg.seq": bson.M{"$in": seqs}, // 序列号在指定列表中
},
},
}
// 2. 投影字段,只返回匹配的消息
projection := bson.M{
"msgs.$": 1, // 只返回匹配条件的数组元素
}
// 3. 执行查询
cursor, err := m.coll.Find(ctx, filter, options.Find().SetProjection(projection))
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
var docs []MsgDocModel
if err = cursor.All(ctx, &docs); err != nil {
return nil, err
}
// 4. 转换为消息数据格式
var msgs []*sdkws.MsgData
for _, doc := range docs {
for _, msgInfo := range doc.Msgs {
if utils.Contain(msgInfo.Msg.Seq, seqs) {
msgs = append(msgs, msgInfo.Msg)
}
}
}
return msgs, nil
}
6.6 消息内容过滤处理
6.6.1 handlerDeleteAndRevoked删除和撤回消息处理
// handlerDeleteAndRevoked 处理删除和撤回消息的显示逻辑
// 路径:open-im-server/pkg/common/storage/controller/msg.go:773-817
func (db *commonMsgDatabase) handlerDeleteAndRevoked(ctx context.Context, userID string, msgs []*model.MsgInfoModel) {
for _, msgInfo := range msgs {
msg := msgInfo.Msg
// 处理已删除消息
if msg.Status == constant.MsgStatusHasDeleted {
if userID == msg.SendID {
// 发送者可以看到删除提示:"你删除了一条消息"
msg.Content = `{"text":"你删除了一条消息"}`
msg.ContentType = constant.Text
} else {
// 接收者看不到已删除消息,从结果中移除
continue
}
}
// 处理撤回消息
if msg.ContentType == constant.Revoke {
var revokeContent struct {
RevokerID string `json:"revokerID"`
RevokerNickname string `json:"revokerNickname"`
RevokeTime int64 `json:"revokeTime"`
SourceMsgSendID string `json:"sourceMsgSendID"`
SessionType int32 `json:"sessionType"`
}
if err := json.Unmarshal([]byte(msg.Content), &revokeContent); err != nil {
continue
}
// 根据撤回者和查看者的关系显示不同文案
if revokeContent.RevokerID == userID {
msg.Content = `{"text":"你撤回了一条消息"}`
} else if revokeContent.SourceMsgSendID == userID {
msg.Content = fmt.Sprintf(`{"text":"%s撤回了一条消息"}`, revokeContent.RevokerNickname)
} else {
// 群聊中其他人的撤回消息
msg.Content = fmt.Sprintf(`{"text":"%s撤回了一条消息"}`, revokeContent.RevokerNickname)
}
msg.ContentType = constant.Text
}
}
}
6.6.2 handlerQuote引用消息处理
// handlerQuote 处理引用消息的权限和内容显示
// 路径:open-im-server/pkg/common/storage/controller/msg.go:818-824
func (db *commonMsgDatabase) handlerQuote(ctx context.Context, userID, conversationID string, msgs []*model.MsgInfoModel) {
for _, msgInfo := range msgs {
msg := msgInfo.Msg
if msg.ContentType != constant.Quote {
continue
}
// 解析引用消息内容
var quoteContent struct {
Text string `json:"text"`
QuoteMessage *sdkws.MsgData `json:"quoteMessage"`
}
if err := json.Unmarshal([]byte(msg.Content), "eContent); err != nil {
log.ZError(ctx, "unmarshal quote content failed", err)
continue
}
// 检查被引用消息的权限
if quoteContent.QuoteMessage != nil {
quotedMsg := quoteContent.QuoteMessage
// 检查用户是否有权限查看被引用的消息
if !db.hasPermissionToViewMessage(ctx, userID, conversationID, quotedMsg) {
// 无权限查看,显示提示文案
quoteContent.QuoteMessage = &sdkws.MsgData{
Content: `{"text":"该消息已被删除"}`,
ContentType: constant.Text,
SendTime: quotedMsg.SendTime,
Seq: quotedMsg.Seq,
}
}
// 更新消息内容
updatedContent, _ := json.Marshal(quoteContent)
msg.Content = string(updatedContent)
}
}
}
// hasPermissionToViewMessage 检查用户是否有权限查看特定消息
func (db *commonMsgDatabase) hasPermissionToViewMessage(ctx context.Context, userID, conversationID string, msg *sdkws.MsgData) bool {
// 1. 检查消息是否已被删除
if msg.Status == constant.MsgStatusHasDeleted {
return userID == msg.SendID // 只有发送者可以看到自己删除的消息
}
// 2. 检查消息是否已被撤回
if msg.ContentType == constant.Revoke {
return true // 撤回消息所有人都可以看到撤回提示
}
// 3. 检查时间权限(是否在用户加入会话之后)
userJoinTime, err := db.getUserJoinTime(ctx, userID, conversationID)
if err == nil && msg.SendTime < userJoinTime {
return false // 加入之前的消息无权查看
}
return true // 默认有权限
}
6.7 云端消息拉取完整流程图
graph TD
A[客户端发起PullMessageBySeqList请求] --> B[服务端PullMessageBySeqs处理]
B --> C[初始化响应结构]
C --> D[遍历序列号范围请求]
D --> E{判断会话类型}
E -->|普通会话| F[获取用户会话信息]
E -->|通知会话| G[构建序列号列表]
F --> H[验证用户权限]
H --> I[调用GetMsgBySeqsRange]
G --> J[调用GetMsgBySeqs]
I --> K[数据库层getMsgBySeqs]
J --> K
K --> L[获取MinSeq边界]
L --> M[获取MaxSeq边界]
M --> N[过滤有效序列号]
N --> O{序列号缓存}
O -->|缓存命中| P[从Redis获取边界]
O -->|缓存未命中| Q[从MongoDB查询边界]
P --> R[调用GetMessageBySeqs]
Q --> S[更新Redis缓存]
S --> R
R --> T[计算文档范围]
T --> U[按文档分组序列号]
U --> V[逐个文档查询]
V --> W[FindSeqs执行MongoDB查询]
W --> X[构建查询条件]
X --> Y[执行find操作]
Y --> Z[投影匹配字段]
Z --> AA[获取原始消息数据]
AA --> BB[handlerDeleteAndRevoked]
BB --> CC[handlerQuote]
CC --> DD{消息内容过滤}
DD -->|已删除消息| EE[权限检查和内容替换]
DD -->|撤回消息| FF[生成撤回提示文案]
DD -->|引用消息| GG[检查被引用消息权限]
DD -->|普通消息| HH[保持原始内容]
EE --> II[合并过滤结果]
FF --> II
GG --> II
HH --> II
II --> JJ[按序列号排序]
JJ --> KK[判断拉取边界]
KK --> LL{拉取顺序}
LL -->|升序| MM[检查maxSeq <= endSeq]
LL -->|降序| NN[检查beginSeq <= minSeq]
MM --> OO[设置isEnd标志]
NN --> OO
OO --> PP[构建PullMsgs响应]
PP --> QQ[添加到响应映射]
QQ --> RR{是否处理完所有会话}
RR -->|否| D
RR -->|是| SS[返回完整响应]
SS --> TT[客户端接收响应]
TT --> UU[触发消息处理流程]
style A fill:#e1f5fe
style TT fill:#e8f5e8
style K fill:#fff3e0
style W fill:#fff3e0
style DD fill:#ffebee
这个详细的实现解析涵盖了从客户端请求到服务端响应的完整流程,包括缓存机制、数据库查询、权限控制和内容过滤等关键环节。每个步骤都考虑了性能优化和数据安全,确保消息同步的准确性和高效性。