Go-Zero 项目开发21: 实现离线消息拉取与会话管理

纲要

  • 核心概念与流程梳理
    • 消息记录的两种查询方式
    • 会话管理:列表、未读计算、更新与建立
    • 离线消息拉取的整体时序
  • 项目代码结构
  • 消息记录查询实现
    • 按消息 ID 单条查询
    • 按时间范围分段查询
  • 会话列表获取与未读消息计算
    • 拉取用户会话列表
    • 批量获取会话详情
    • 计算未读数量与显示状态
  • 会话更新逻辑
    • 批量更新已读序号
    • 幂等性与初始化保护
  • 会话建立流程
    • 生成唯一会话 ID
    • 存在性校验与幂等
    • 双向记录添加(封装的公共方法)
  • WebSocket 与消息收发回顾(简要)
    • 连接鉴权与心跳
    • 私聊推送模型
    • Kafka 异步解耦与 ACK 机制
  • 离线消息拉取完整链路
    • 时序图与关键步骤
  • 测试与常见问题
    • 字段类型匹配
    • 会话 ID 与发送者缺失
    • 发送时间处理
  • 总结

核心概念与流程梳理

在即时通讯系统中,"离线消息"指的是当目标用户不在线时,为其暂存的消息。用户下次上线后需要主动拉取这些消息,并更新会话已读状态。整体涉及以下子模块:

  • 消息记录查询:支持按单 ID 查询和历史时间段分页查询。
  • 会话管理:会话(Conversation)是用户与目标(单聊或群聊)的聊天窗口,需要管理会话列表、未读计数、更新已读序号以及会话的创建。
  • 离线消息拉取:客户端获取会话列表 → 计算未读消息数 → 点击某个会话拉取最新消息 → 标记已读。

本文基于 go-zero 框架,结合 WebSocket 和 Kafka,实现上述能力。

项目代码结构

项目基于 go-zero 微服务划分,核心服务 message 提供 RPC 接口供 API 层调用,内部操作 MongoDB 存储消息记录和会话信息。关键目录如下:

dir 复制代码
.
├── message 
│   ├── message.proto           # RPC 定义 
│   ├── internal 
│   │   ├── logic               # 业务逻辑层 
│   │   │   ├── getmessagerecord 
│   │   │   ├── getconversations 
│   │   │   ├── updateconversation 
│   │   │   └── createconversation 
│   │   ├── model               # 数据模型(MongoDB)
│   │   └── server              # 启动入口 
│   └── go.mod 
├── ws                           # WebSocket 服务 
│   └── ...
└── api                          # API 网关服务 
    └── ...

所有代码基于 go-zero@latest 生成,并使用了最新版的依赖。

消息记录查询实现

RPC 接口定义(message.proto 片段):

proto 复制代码
syntax = "proto3";
 
package message;
 
service Message {
    rpc GetMessageRecord(GetMessageRecordReq) returns (GetMessageRecordResp);
}
 
message GetMessageRecordReq {
    string conversationId = 1; // 会话 ID 
    int64  maxId          = 2; // 若不为空,按单条消息 ID 查询 
    int64  startTime      = 3; // 起始时间戳 
    int64  endTime        = 4; // 结束时间戳 
    int32  pageSize       = 5;
}
 
message Message {
    int64  msgId          = 1;
    string conversationId = 2;
    string senderId       = 3;
    string receiverId     = 4;
    int32  msgType        = 5;
    string content        = 6;
    int64  sendTime       = 7;
}
 
message GetMessageRecordResp {
    repeated Message messages = 1;
}

logic 层中,GetMessageRecord 根据 maxId 是否传递来决定查询方式。

go 复制代码
package getmessagerecord 
 
import (
    "context"
    "errors"
 
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "go.mongodb.org/mongo-driver/mongo/options"
 
    "message/internal/svc"
    "message/pb"
)
 
func (l *GetMessageRecordLogic) GetMessageRecord(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
    if in.MaxId != 0 {
        return l.queryByID(in)
    }
    return l.queryByTimeRange(in)
}
 
func (l *GetMessageRecordLogic) queryByID(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
    var msg pb.Message 
    err := l.svcCtx.MongoCol.FindOne(l.ctx, bson.M{
        "conversationId": in.ConversationId,
        "msgId":          in.MaxId,
    }).Decode(&msg)
    if err != nil {
        if errors.Is(err, mongo.ErrNoDocuments) {
            return &pb.GetMessageRecordResp{}, nil 
        }
        return nil, err 
    }
    return &pb.GetMessageRecordResp{Messages: []*pb.Message{&msg}}, nil 
}
 
func (l *GetMessageRecordLogic) queryByTimeRange(in *pb.GetMessageRecordReq) (*pb.GetMessageRecordResp, error) {
    filter := bson.M{
        "conversationId": in.ConversationId,
        "sendTime": bson.M{
            "$gte": in.StartTime,
            "$lte": in.EndTime,
        },
    }
    opts := options.Find().SetLimit(int64(in.PageSize)).SetSort(bson.M{"sendTime": -1})
    cursor, err := l.svcCtx.MongoCol.Find(l.ctx, filter, opts)
    if err != nil {
        return nil, err 
    }
    defer cursor.Close(l.ctx)
    var msgs []*pb.Message 
    if err := cursor.All(l.ctx, &msgs); err != nil {
        return nil, err 
    }
    return &pb.GetMessageRecordResp{Messages: msgs}, nil 
}

这样便支持了两种查询方式:精确 ID 查询和时间窗口分页查询。

会话列表获取与未读消息计算

获取会话列表分为三个步骤:

  1. 查询用户的会话列表(存储了用户参与的所有会话 ID)。
  2. 根据会话 ID 集合批量获取会话详情。
  3. 迭代每个会话,计算是否包含未读消息,并修正显示状态。

接口定义(message.proto 新增):

proto 复制代码
rpc GetConversations(GetConversationsReq) returns (GetConversationsResp);
 
message GetConversationsReq {
    string userId = 1;
}
 
message Conversation {
    string conversationId = 1;
    int32  chatType       = 2; // 1:私聊 2:群聊 
    int64  totalMsgCount  = 3;
    int64  readMsgCount   = 4;
    int64  unreadCount    = 5;
    bool   isShow         = 6;
}
 
message GetConversationsResp {
    repeated Conversation conversations = 1;
}

实现代码:

go 复制代码
package getconversations 
 
import (
    "context"
 
    "go.mongodb.org/mongo-driver/bson"
    "go.mongodb.org/mongo-driver/mongo"
    "message/internal/svc"
    "message/pb"
)
 
func (l *GetConversationsLogic) GetConversations(in *pb.GetConversationsReq) (*pb.GetConversationsResp, error) {
    // 1. 获取用户会话列表记录 
    userConvDoc, err := l.svcCtx.UserConvModel.FindOne(l.ctx, in.UserId)
    if err != nil && err != mongo.ErrNoDocuments {
        return nil, err 
    }
    // 如果没有会话记录,返回空列表(正常情况)
    if userConvDoc == nil || len(userConvDoc.ConversationIds) == 0 {
        return &pb.GetConversationsResp{}, nil 
    }
 
    // 2. 批量获取会话详情 
    convs, err := l.svcCtx.ConversationModel.FindByIds(l.ctx, userConvDoc.ConversationIds)
    if err != nil {
        return nil, err 
    }
 
    // 3. 计算未读数量并调整显示状态 
    resp := make([]*pb.Conversation, 0, len(convs))
    for _, conv := range convs {
        // 如果 readMsgCount < totalMsgCount,说明有未读 
        unread := int64(0)
        if conv.ReadMsgCount < conv.TotalMsgCount {
            unread = conv.TotalMsgCount - conv.ReadMsgCount 
            // 如果会话被用户隐藏,有新消息时重新置为显示 
            if !conv.IsShow {
                conv.IsShow = true 
                // 注意:这里仅对返回结果修改,不持久化,实际更新在 UpdateConversation 中处理 
            }
        }
        resp = append(resp, &pb.Conversation{
            ConversationId: conv.ConversationId,
            ChatType:       conv.ChatType,
            TotalMsgCount:  conv.TotalMsgCount,
            ReadMsgCount:   conv.ReadMsgCount,
            UnreadCount:    unread,
            IsShow:         conv.IsShow,
        })
    }
    return &pb.GetConversationsResp{Conversations: resp}, nil 
}

此处关键点:会话列表可能为空,应视为正常,不报错;未读数量通过差值计算,且当有新消息时自动恢复隐藏的会话。

会话更新逻辑

更新会话通常由客户端定时触发,或用户阅读消息后主动提交。接口允许客户端传递一个 map[string]int64(会话 ID -> 本次已读数量),服务端累加到历史已读数上。

接口定义:

proto 复制代码
rpc UpdateConversation(UpdateConversationReq) returns (UpdateConversationResp);
 
message UpdateConversationReq {
    string userId        = 1;
    map<string, int64> readMap = 2; // key: conversationId, value: 客户端最新已读消息序号增量 
}
 
message UpdateConversationResp {}

实现:

go 复制代码
package updateconversation 
 
import (
    "context"
    "go.mongodb.org/mongo-driver/mongo"
    "message/internal/svc"
    "message/pb"
)
 
func (l *UpdateConversationLogic) UpdateConversation(in *pb.UpdateConversationReq) (*pb.UpdateConversationResp, error) {
    // 用户会话列表必然存在,因为建立会话时已经创建 
    for convId, readIncrement := range in.ReadMap {
        conv, err := l.svcCtx.ConversationModel.FindOne(l.ctx, convId)
        if err != nil {
            // 不存在则初始化 
            conv = &model.Conversation{
                ConversationId: convId,
                TotalMsgCount:  0,
                ReadMsgCount:   0,
            }
            if err != mongo.ErrNoDocuments {
                return nil, err 
            }
        }
        // 记录历史已读数 
        oldRead := conv.ReadMsgCount 
        // 更新已读数:历史上已经读的 + 本次客户端上报增量 
        newRead := oldRead + readIncrement 
        // 安全保护:已读数量不能超过总消息数 
        if newRead > conv.TotalMsgCount {
            newRead = conv.TotalMsgCount 
        }
        conv.ReadMsgCount = newRead 
        // 持久化 
        if err := l.svcCtx.ConversationModel.Update(l.ctx, conv); err != nil {
            return nil, err 
        }
    }
    return &pb.UpdateConversationResp{}, nil 
}

该实现支持批量更新,并通过历史增量累加保证数据一致。

会话建立流程

建立单聊会话时,需要:

  1. 根据双方用户 ID 按同一规则生成唯一会话 ID(例如 combine(userA, userB))。
  2. 查询该会话是否已存在,存在则直接返回(幂等)。
  3. 若不存在,创建会话记录,并为双方用户分别添加会话列表记录。

接口定义:

proto 复制代码
rpc CreateConversation(CreateConversationReq) returns (CreateConversationResp);
 
message CreateConversationReq {
    string senderId   = 1;
    string receiverId = 2;
    int32  chatType   = 3; // 1:私聊 
    bool   showForSender = 4; // 发送方是否在列表显示 
}
 
message CreateConversationResp {
    string conversationId = 1;
}

实现:

go 复制代码
package createconversation 
 
import (
    "context"
    "sort"
    "strings"
 
    "go.mongodb.org/mongo-driver/mongo"
    "message/internal/svc"
    "message/internal/model"
    "message/pb"
)
 
// genConversationId 生成唯一会话 ID:私聊即两个用户 ID 排序后拼接 
func genConversationId(uid1, uid2 string) string {
    ids := []string{uid1, uid2}
    sort.Strings(ids)
    return strings.Join(ids, "_")
}
 
func (l *CreateConversationLogic) CreateConversation(in *pb.CreateConversationReq) (*pb.CreateConversationResp, error) {
    convId := genConversationId(in.SenderId, in.ReceiverId)
 
    // 检查会话是否已存在 
    conv, err := l.svcCtx.ConversationModel.FindOne(l.ctx, convId)
    if err != nil && err != mongo.ErrNoDocuments {
        return nil, err 
    }
    if conv != nil {
        // 已经建立,直接返回 
        return &pb.CreateConversationResp{ConversationId: convId}, nil 
    }
 
    // 创建会话记录 
    newConv := &model.Conversation{
        ConversationId: convId,
        ChatType:       in.ChatType,
        TotalMsgCount:  0,
        ReadMsgCount:   0,
        IsShow:         true,
    }
    if err := l.svcCtx.ConversationModel.Insert(l.ctx, newConv); err != nil {
        return nil, err 
    }
 
    // 为双方添加会话列表记录(发送方显示,接收方默认不显示)
    if err := l.addUserConversation(in.SenderId, convId, true); err != nil {
        return nil, err 
    }
    if err := l.addUserConversation(in.ReceiverId, convId, false); err != nil {
        return nil, err 
    }
 
    return &pb.CreateConversationResp{ConversationId: convId}, nil 
}
 
// addUserConversation 向用户会话列表中添加一条会话记录,若不存在则创建,存在则追加(幂等)
func (l *CreateConversationLogic) addUserConversation(userId, convId string, isShow bool) error {
    userConv, err := l.svcCtx.UserConvModel.FindOne(l.ctx, userId)
    if err != nil && err != mongo.ErrNoDocuments {
        return err 
    }
    if userConv == nil {
        // 初始化用户会话列表 
        userConv = &model.UserConversation{
            UserId:          userId,
            ConversationIds: []string{convId},
        }
        return l.svcCtx.UserConvModel.Insert(l.ctx, userConv)
    }
    // 已存在,判断是否已包含该会话 ID 
    if !contains(userConv.ConversationIds, convId) {
        userConv.ConversationIds = append(userConv.ConversationIds, convId)
        return l.svcCtx.UserConvModel.Update(l.ctx, userConv)
    }
    return nil 
}
 
func contains(slice []string, item string) bool {
    for _, s := range slice {
        if s == item {
            return true 
        }
    }
    return false 
}

这里使用排序合并的方式生成唯一 ID,并确保了接口的幂等性。发起方调用时,发送方往往需要立刻看到该会话,因此 showForSender=true,而被添加方默认不显示,直到对方主动发送消息。

WebSocket 与消息收发模型回顾

在完整的 IM 系统中,离线消息的拉取依赖于消息能够可靠存储,而实时推送则依赖 WebSocket 连接和 Kafka 异步模型。

  • WebSocket 鉴权 :客户端携带用户服务的 token 连接 WebSocket 服务,服务端调用 user 服务 Auth 接口验证身份,并获取 userId 后建立长连接。
  • 心跳检测:利用 gRPC 长连接的空闲检测器,定时发送/检测心跳,及时释放死连接。
  • 私聊消息推送 :发送方将消息发送至 WebSocket 服务,服务根据 receiverId 查找到对应连接进行推送。
  • 消息收发模型(Kafka 异步):WebSocket 接收到消息后,不直接处理存储和推送,而是将消息写入 Kafka;后台消费者读取消息,完成数据库写入和推送。收与发解耦,提高吞吐和可靠性。
  • ACK 确认机制:参考 TCP 握手设计,服务端推送消息后等待客户端确认,超时重推,确保消息可靠性。

离线消息拉取完整链路

下面用 Mermaid 时序图展示用户上线后拉取离线消息的完整流程:
WebSocket MongoDB message RPC API 网关 客户端 WebSocket MongoDB message RPC API 网关 客户端 #mermaid-svg-3OgZjU6vSW0c7bxA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3OgZjU6vSW0c7bxA .error-icon{fill:#552222;}#mermaid-svg-3OgZjU6vSW0c7bxA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3OgZjU6vSW0c7bxA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3OgZjU6vSW0c7bxA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3OgZjU6vSW0c7bxA .marker.cross{stroke:#333333;}#mermaid-svg-3OgZjU6vSW0c7bxA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3OgZjU6vSW0c7bxA p{margin:0;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3OgZjU6vSW0c7bxA .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .sequenceNumber{fill:white;}#mermaid-svg-3OgZjU6vSW0c7bxA #sequencenumber{fill:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3OgZjU6vSW0c7bxA .messageText{fill:#333;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA .labelText,#mermaid-svg-3OgZjU6vSW0c7bxA .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .loopText,#mermaid-svg-3OgZjU6vSW0c7bxA .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3OgZjU6vSW0c7bxA .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3OgZjU6vSW0c7bxA .noteText,#mermaid-svg-3OgZjU6vSW0c7bxA .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3OgZjU6vSW0c7bxA .actorPopupMenu{position:absolute;}#mermaid-svg-3OgZjU6vSW0c7bxA .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3OgZjU6vSW0c7bxA .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3OgZjU6vSW0c7bxA .actor-man circle,#mermaid-svg-3OgZjU6vSW0c7bxA line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3OgZjU6vSW0c7bxA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 实时消息推送通过WebSocket完成 获取会话列表GetConversations(userId)查询用户会话ID列表批量查询会话详情会话列表(含未读计数)显示未读红点点击某个会话,拉取消息GetMessageRecord(convId, startTime, endTime)查询消息记录消息列表展示消息标记已读UpdateConversation(userId, readMap)更新已读消息序号成功更新本地未读计数

测试与常见问题

在开发过程中,遇到过以下几个典型问题并已修复:

  1. 字段类型不匹配

    调用创建会话接口时,由于 protobuf 生成的字段类型与 MongoDB 存储字段类型不一致导致报错。修正措施:统一使用 string 类型存储用户 ID。

  2. 消息记录中会话 ID 和发送者缺失

    消息消费者在处理 Kafka 任务时,未设置 conversationIdsenderId,导致消息虽然写入却无法正确关联会话。修正:在推送消息至 Kafka 前,填充这两个字段;消费时直接使用。

  3. 发送时间为空

    最初由客户端传递发送时间,不安全且可能为空。改为服务端在 WebSocket 收到消息时记录服务端时间 time.Now().Unix(),确保时间准确。

  4. 会话更新未生效

    因为会话 ID 未正确传递到更新逻辑,需在 Kafka 消费者中显式设置 conversationId,之后更新才成功执行。

通过以上调整,离线消息的拉取和已读更新均可正确运行,消息总量和未读计数保持一致。

总结

本文详细介绍了使用 go-zero 实现即时通讯中离线消息拉取和会话管理的全过程。核心内容包括:

  • 消息记录的双模式查询设计;
  • 会话列表的获取与未读消息实时计算;
  • 会话更新与幂等建立;
  • 结合 WebSocket、Kafka 的实时推送模型;
  • 典型问题的排查与解决。

掌握这些模块后,可以轻松扩展群聊、消息漫游等高级特性。所有实现均基于 go-zero@latest,确保代码的现代化与可维护性。

相关推荐
环境栈笔记1 小时前
高性价比指纹浏览器推荐与选型:如何对照价格和实际可用功能筛选候选
前端·人工智能·后端·自动化
卷无止境1 小时前
Python虚拟环境江湖:从venv到uv,如何避开依赖冲突的坑
后端·python
米码收割机2 小时前
【Python】Django 电子设备商城系统(源码+说明文档)[独一无二]
开发语言·python·django
互联网中的一颗神经元2 小时前
小白python入门 - 38. 动态内容:接口优先与自动化扫盲
开发语言·python·自动化
卷无止境2 小时前
模块与包:Python 代码组织的两层逻辑
后端·python
黑客-秋凌2 小时前
使用Python+selenium实现第一个自动化测试脚本
开发语言·自动化测试·软件测试·python·selenium·测试工具
明月_清风2 小时前
🛡️ Web3 安全入门:新手防骗完全指南
后端·web3
明月_清风2 小时前
🔐 Solidity 完全指南:关键语法解析与智能合约中的核心作用
后端·web3
geovindu3 小时前
CSharp: Iterative Algorithms
开发语言·后端·算法·c#·.net·迭代算法
一只月月鸟呀3 小时前
移动端tap与click的区别 && 点透事件
开发语言·前端·javascript