云计算百科
云计算领域专业知识百科平台

Go IM 源码实战(12):建个 500 人的群,写的是 500 条记录

你好,我是利威尔xu。

这是**「Go IM 源码实战」系列第 12 篇**。前面讲的是"一对一"的链路,这一篇开始讲**“一对多”**:群组。

先说一个建群时的真实开销:建一个 500 人的群,不是"插一条群记录",而是"插 1 条群记录 + 500 条成员记录 + 500 条会话记录 + 1 条通知消息,还要推给 500 个人"。 而这些操作跨越了 group、conversation、msg、push 四个服务。

所以群这块的难点从来不是"建群"这个功能,而是三个数:5000 人的群改个群名要写多少条?一个人退群要不要动别人的数据?群主退群会怎样?

这篇讲四件事:群成员这张中间表怎么设计(为什么不能塞数组)、三级角色的权限矩阵(为什么不做成可配置)、禁言为什么必须两层、以及"群主不能退群"这条不变量背后的真实原因。

源码位置(全系列同一份):

  • GitHub:https://github.com/CYQ111999/IM-Server
  • 基线:OpenIM Server v3.8 瘦身版,main @ 07f9ff6
  • 本篇涉及:pkg/common/storage/model/group_member.go、internal/rpc/group/group.go、internal/rpc/group/notification.go、pkg/common/storage/controller/group.go

前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。

文章目录

    • 一、群成员存哪:为什么不能塞进群文档的数组
      • 方案一:群文档里存成员数组
      • 方案二:独立成员表(本项目的选择)
      • 三个字段值得单独说
    • 二、三级角色:一个刻意的"不配置化"
      • 权限矩阵(读源码时最该做的事)
    • 三、禁言:为什么必须是两层
    • 四、建群一次要写多少东西
    • 五、"这个群有多少人":三种算法和各自的天花板
    • 六、成员变更通知:三层开关和扇出成本
    • 七、群主转让:一个必须原子完成的两步
    • 八、解散群:两种模式,和那个"要删 8 类缓存"的教训
      • 两个模式写的版本日志不一样,为什么
    • 九、群 ID 生成:为什么允许调用方指定
    • 十、PopulateGroupMember:N+1 查询的标准解法
    • 十一、踩坑记录:三个坑,一共 11 小时
    • 十二、面试会怎么问
    • 十三、小结:一张中间表、两个不变量、三种算法

一、群成员存哪:为什么不能塞进群文档的数组

一个群有 N 个成员,一个人有 M 个群。这是典型的多对多关系,两种存法。

方案一:群文档里存成员数组

{
"group_id": "123",
"group_name": "技术群",
"members": [
{"user_id": "a", "role": 1},
{"user_id": "b", "role": 3}
// … 500 人
]
}

查"群的所有成员"确实是一次查询,很快。但三个缺点是致命的:

问题具体表现
文档大小 Mongo 单文档上限 16MB,每条成员 200 字节 → 最多 8 万人(看着够,但读群信息会连带拉回整个数组)
反向查询 "我加入了哪些群"要遍历所有群文档扫数组 —— O(群总数)
并发冲突 两个人同时改不同成员,都重写整个数组,后写的覆盖前写的

第三条是最难缠的。 它不会报错,只会"丢更新"——A 改了成员 X 的禁言时间,B 同时改了成员 Y 的群昵称,最后只有一个生效。

方案二:独立成员表(本项目的选择)

group_member 集合:
{ group_id: "123", user_id: "a", role_level: 1, join_time: …, mute_end_time: … }
{ group_id: "123", user_id: "b", role_level: 3, … }
{ group_id: "456", user_id: "a", role_level: 1, … }

一条记录 = 一个"用户在某群里的身份",配三个索引:

索引支撑哪个查询
(group_id, user_id) 唯一 查单个成员、防重复入群
group_id 查群的所有成员
user_id 查某人加入的所有群

📌 M:N 关系用中间表,不要塞数组。 数组只适合"数量有上限、总是整体读写"的场景(比如一条消息的 @ 列表)。

三个字段值得单独说

type GroupMember struct {
GroupID string `bson:"group_id"`
UserID string `bson:"user_id"`
Nickname string `bson:"nickname"` // 群昵称
FaceURL string `bson:"face_url"`
RoleLevel int32 `bson:"role_level"` // 1=owner 2=admin 3=ordinary
JoinTime time.Time `bson:"join_time"`
JoinSource int32 `bson:"join_source"`
InviterUserID string `bson:"inviter_user_id"`
OperatorUserID string `bson:"operator_user_id"`
MuteEndTime time.Time `bson:"mute_end_time"`
Ex string `bson:"ex"`
}

① Nickname 是冗余的。 它和 user.Nickname 可能不同(群昵称是群内独立的)。为什么存一份?因为"群成员列表"是高频查询,如果每个人昵称都去用户表查,500 人的群就是 501 次查询。

代价是:用户改了昵称,要同步更新所有群的成员记录——这就是 PopulateGroupMember(发现空了就补)和 NotificationUserInfoUpdate(资料变更通知所有群)存在的原因。

② MuteEndTime 存的是"结束时间"而不是"剩余秒数"。 因为剩余秒数会随时间变化,存下来立刻就过期了。存结束时间,判断禁言就是 MuteEndTime.After(time.Now())。

📌 "存绝对时间,不存相对时间"是一条通用原则。 缓存过期、任务延迟、限流窗口、token 过期……凡是"还剩多久"的东西,都应该存"到什么时候"。

③ JoinSource 记录了入群方式(邀请/扫码/搜索/导入)。用于统计和风控——一个"扫码进来又立刻退群"的账号,和"被好友邀请"的账号,风控评级完全不同。

二、三级角色:一个刻意的"不配置化"

RBAC 完整版有四张表(角色、权限、角色-权限、用户-角色)。本项目用了一个极简版:角色写死三级,权限写死在代码的 switch 里。

RoleLevel: 1 = GroupOwner 群主
2 = GroupAdmin 管理员
3 = GroupOrdinaryUsers 普通成员

switch opMember.RoleLevel {
case constant.GroupOwner:
// 什么都能干
case constant.GroupAdmin:
// 不能动群主和其他管理员
case constant.GroupOrdinaryUsers:
return nil, errs.ErrNoPermission.WrapMsg("opUserID no permission")
}

为什么不设计成可配置的权限表?

因为 IM 的群权限是行业标准化的——几乎所有产品都是"群主 > 管理员 > 成员",边界也大同小异。做成配置会带来三个成本:

  • 配置复杂度(每个群一套权限矩阵);
  • 前端渲染复杂度(按钮显不显示取决于权限矩阵);
  • 一致性风险(同一个操作在不同群行为不同,用户会困惑)。
  • 代价是灵活性差:想加"只读成员""审计员"这类角色,得改代码。

    📌 这里有个很值得记住的判断:不是所有东西都值得做成可配置。稳定的领域模型应该硬编码,变化频繁的部分才配置化。 群角色属于前者(十年不变),入群验证策略属于后者(每个群都可能不同)。

    权限矩阵(读源码时最该做的事)

    我把散在十几个 if 里的判断汇总成一张表:

    操作群主管理员普通成员系统管理员
    修改群信息 ✅ ✅ ❌ ✅
    修改群公告 ✅ ✅ ❌ ✅
    邀请成员 ✅ ✅ ✅(视验证策略) ✅
    踢人 ✅ ✅(不能踢群主/管理员) ❌ ✅
    设置管理员 ✅ ❌ ❌ ✅
    禁言成员 ✅ ✅(不能禁言群主/管理员) ❌ ✅
    群禁言(全员) ✅ ✅ ❌ ✅
    转让群主 ✅(只能转给自己名下的) ❌ ❌ ✅
    解散群 ✅ ❌ ❌ ✅
    退群 ❌ ✅ ✅ ✅

    “群主不能退群” 是这张表里唯一一个反直觉的:

    if member.RoleLevel == constant.GroupOwner {
    return nil, errs.ErrNoPermission.WrapMsg("group owner can't quit")
    }

    因为群主一退,群就没有主了——没人能转让、没人能解散、没人能设管理员。这是一个业务不变量:要么先转让,要么解散。

    📌 好的代码会把不变量写成显式校验,而不是"靠流程约束"。 如果这里不拦,运营侧就一定会出现"群主已离职,群无人管理"的脏数据。

    三、禁言:为什么必须是两层

    "禁言"在 IM 里有两个完全不同的粒度:

    群禁言成员禁言
    范围 整个群,所有人不能发言 某个成员
    存在哪 group.status = GroupStatusMuted group_member.mute_end_time
    谁能设 群主/管理员 群主/管理员(有层级限制)
    解除 手动 CancelMuteGroup 到期自动解除,或手动

    为什么不能合成一层? 因为它们的语义完全不同:

    • 群禁言是群级状态,一次设置影响所有人,改一个字段就够;
    • 成员禁言是关系级状态,只影响一个人,存在成员记录上。

    如果合成一层(比如"给每个人都设一个禁言时间"),群禁言就要写 N 条记录——5000 人的群,一次"全员禁言"写 5000 条,还要在有人入群时补写。用群级字段表达群级语义,是省 5000 倍写放大的关键。

    📌 这个思路很通用:判断一个状态该存在哪一层,就问"它描述的是谁"。 描述群的存群表,描述"人和群关系"的存关系表,描述人的存用户表。放错层的代价通常是写放大或者查询放大。

    四、建群一次要写多少东西

    CreateGroup 的真实开销,按服务拆:

    步骤写什么量级(500 人群)
    1 群记录 1 条
    2 群成员记录 500 条(批量)
    3 会话记录 500 条(每人一条,跨 conversation 服务)
    4 建群通知消息 1 条(进消息流水线)
    5 推送 500 个推送任务

    注意第 3 步是跨服务的。 群服务建完群,要调 conversation 服务给每个成员建会话——否则用户打开 App 看不到这个群。

    而且这 500 条会话记录,每条都要带 minSeq/maxSeq 水位(第 7 篇讲的),初始都是 0。这个设计带来一个后果:新入群的人看不到入群前的历史消息(因为他的水位从 0 开始,但会话 minSeq 已经很大了,下界取 max 就把它挡住了)。

    📌 这就是第 7 篇"用户级水位"的存在意义,而这里正是它的第二个应用场景(第一个是清空聊天记录)。同一个机制,多种用途——这是抽象做得好的标志。

    五、“这个群有多少人”:三种算法和各自的天花板

    看着是最简单的一个需求,实际有三种做法,各有各的坑。

    方案一:实时 count

    db.group_member.countDocuments({group_id: "123"})

    问题是它高频。每个群会话列表项都要显示人数,用户有 30 个群,打开 App 就是 30 次 count,每次扫几百条。

    方案二:群文档里存 member_count 字段,变更时 $inc

    update := bson.M{"$inc": bson.M{"member_count": 1}}

    问题是容易算错:

    • 批量踢 100 人,循环 100 次 $inc -1,中间失败一次数就错了;
    • 两个人同时 $inc,虽然 Mongo 是原子的,但业务上"踢 100 人"这个操作本身可能部分成功;
    • 一旦错了,没有任何机制能自动纠正——它是个纯增量,没有基线。

    方案三:缓存 + Cache-Aside(本项目的做法)

    读:缓存有 → 直接返回;没有 → 聚合 count → 回填缓存
    写:成员变更 → 删缓存(下次读时重新 count)

    核心思想:不维护精确计数,而是缓存查询结果。

    区别很关键:

    $inc 计数Cache-Aside
    错了会怎样 永久错(没有基线) 下次读就纠回来
    代价 无 变更后第一次读会慢(回源 count)

    📌 这是一个非常值得记住的取舍:凡是"可以被重新计算出来"的数字,都不要维护增量,直接缓存计算结果。 因为增量一定会错,而结果可以被重算。

    反过来,凡是"算不出来"的(比如"这个用户累计发过多少条消息"这种跨表聚合、成本高到不能实时算的),才需要维护增量 + 定期对账。

    六、成员变更通知:三层开关和扇出成本

    "A 加入了群"这件事,要让全群所有人看到一条系统消息——一次 1 对 N 的扇出。

    按第 9 篇讲的,扇出发生在推送层,存储层只写一份。

    但这里有个更隐蔽的成本:每天 20 个人进出的 500 人群,一天就有 20 条系统消息,用户翻聊天记录全是"xxx 加入了群聊"。

    本项目给了三层控制:

    层开关作用
    1 调用方传 sendMessage: false 这次不发
    2 config/notification.yml 的 memberEnter.enable: false 这类通知全局不发
    3 客户端渲染折叠 连续多条"xx 加入"合并显示

    📌 "默认行为可关闭"是个好设计原则:基础设施不该强制业务接受某种用户体验。

    但我要指出一个容易被忽略的点:即使 sendMessage: false,推送仍然会发生(因为通知消息和推送是两条链)。想彻底不发,得同时把 Options 里的 IsOfflinePush / IsUnreadCount 关掉——这是第 4 篇讲的 11 个开关的又一个用途。

    七、群主转让:一个必须原子完成的两步

    转让群主要改两条成员记录:

    • 旧群主:role_level 1 → 3(或 2)
    • 新群主:role_level 3(或 2)→ 1

    如果第二步失败,就会得到一个"没有群主"的群——旧群主已经降级,新群主没升上去。而这个群的所有管理操作(设管理员、踢人、解散)都需要群主权限,等于这个群从此不可管理。

    所以必须在事务里做:

    if err := g.db.TransferGroupOwner(ctx, req.GroupID, req.OldOwnerUserID, req.NewOwnerUserID, newOwner.RoleLevel); err != nil {

    注意最后那个参数 newOwner.RoleLevel——新群主原来的角色。它的用途是决定旧群主降级成什么:

    新群主原来是旧群主降级成理由
    管理员 管理员 保持管理员数量不变
    普通成员 普通成员 不凭空多一个管理员

    📌 这是一个很细腻的设计:转让不该改变群的管理力量对比。 大部分系统会简单地把旧群主降成普通成员,结果"转让一次少一个管理员"——运营侧会发现管理员越转越少。

    这类"副作用要守恒"的思考方式很有价值:任何变更操作,先问一句"它会不会意外减少某个东西"(管理员数、未读数、在线人数、配额)。

    八、解散群:两种模式,和那个"要删 8 类缓存"的教训

    DismissGroup(ctx, req.GroupID, req.DeleteMember),deleteMember 决定两种模式:

    模式做什么适用
    false(仅解散) 群状态改 Dismissed,成员记录保留 软删,可恢复;或留成员关系做分析
    true(解散并删成员) 状态改 Dismissed + 删所有成员记录 + 给每人写一条版本日志 彻底删,释放空间

    模式二的实现(controller/group.go:273-310)值得逐行看,因为它暴露了"群模块到底有多少缓存":

    func (g *groupDatabase) DismissGroup(ctx context.Context, groupID string, deleteMember bool) error {
    return g.ctxTx.Transaction(ctx, func(ctx context.Context) error {
    c := g.cache.CloneGroupCache()
    if err := g.groupDB.UpdateStatus(ctx, groupID, constant.GroupStatusDismissed); err != nil { return err }
    if deleteMember {
    userIDs, err := g.cache.GetGroupMemberIDs(ctx, groupID)
    if err := g.groupMemberDB.Delete(ctx, groupID, nil); err != nil { return err } // nil = 删全部
    c = c.DelJoinedGroupID(userIDs…). // 每个人的"我加入的群"
    DelGroupMemberIDs(groupID). // 群的成员 ID 列表
    DelGroupsMemberNum(groupID). // 群成员数
    DelGroupMembersHash(groupID). // 成员列表 hash(增量同步用)
    DelGroupAllRoleLevel(groupID). // 全部角色映射
    DelGroupMembersInfo(groupID, userIDs…). // 每个成员详情
    DelMaxGroupMemberVersion(groupID). // 群成员版本号
    DelMaxJoinGroupVersion(userIDs…) // 每个人的"加入群"版本号
    for _, userID := range userIDs {
    g.groupMemberDB.JoinGroupIncrVersion(ctx, userID, []string{groupID}, model.VersionStateDelete)
    }
    }
    return c.DelGroupsInfo(groupID).ChainExecDel(ctx)
    })
    }

    要删 8 类缓存。 这张列表本身就是理解群模块最好的材料:

    缓存key 维度内容
    JoinedGroupID userID 我加入了哪些群
    GroupMemberIDs groupID 群有哪些成员
    GroupsMemberNum groupID 群有多少人
    GroupMembersHash groupID 成员列表 hash(增量同步)
    GroupAllRoleLevel groupID 全部角色
    GroupMembersInfo groupID + userID 成员详情
    MaxGroupMemberVersion groupID 群成员版本
    MaxJoinGroupVersion userID 我的加入群版本

    为什么要这么多维度? 因为查询模式多:按用户查、按群查、按群+用户查、按群查数量。每个查询模式一个 key 维度,这是多维度缓存的必然结果,代价就是失效要删一大堆。

    ⚠️ 这里有个真实的易错点:漏删任何一个,就会出现"群解散了但我列表里还有"或者"成员数显示 500 实际 0 人"。ChainExecDel 用 pipeline 把删除攒到最后一次执行(减少往返),但不保证原子性——执行到一半失败,缓存就处于"部分失效"的中间态。

    📌 多维度缓存的通用解法:给缓存加"版本号"或"命名空间代际"(比如 key 前缀带 gen,失效时把 gen +1),这样一次失效就是 O(1),不用逐个删。代价是旧代的 key 要靠 TTL 自然淘汰。缓存维度超过 3 个,就该考虑这个方案了。

    两个模式写的版本日志不一样,为什么

    模式二给每个成员写 VersionStateDelete(“你退出了这个群”)。

    模式一写的是:

    g.groupMemberDB.MemberGroupIncrVersion(ctx, groupID, []string{""}, model.VersionStateUpdate)

    注意 []string{""}——一个空字符串。

    这是哨兵值,表达"群本身变了(状态改为解散),但没有具体哪个成员变了"。客户端收到这个变更后重新拉群信息,发现 status = dismissed。

    📌 用特殊 ID 表达"非成员维度的变更",是个很实用的技巧(好友关系链里也有类似做法)。它的前提是 ID 空间里保留了一个不可能出现的值——所以 userID 的字符校验(第 11 篇讲的"不能含冒号")在这里同样重要:如果允许空字符串当 userID,这个哨兵就会被误解。

    九、群 ID 生成:为什么允许调用方指定

    group.go:165-192:

    func (g *groupServer) GenGroupID(ctx context.Context, groupID *string) error {
    if *groupID != "" {
    // 调用方指定了 ID:检查是否已存在
    _, err := g.db.TakeGroup(ctx, *groupID)
    if err == nil {
    return servererrs.ErrGroupIDExisted.WrapMsg("group id existed " + *groupID)
    }
    …

    两种模式:

    模式谁生成适用
    服务端生成 IM 分配(雪花 ID 或类似) 客户端直接建群
    调用方指定 业务后端给 ID 业务系统要"群 ID 和业务主键一致"

    第二种和第 11 篇"userID 必须与业务主键一致"是同一个道理:如果群里对应一个业务对象(比如一个项目、一个订单、一个班级),群 ID 就应该等于那个对象的 ID。

    “指定 ID"这条路径必须做唯一性检查,而且要用唯一索引兜底——只靠"先查再插"在并发下一定会有重复(两个请求同时查到"不存在”,然后都插成功)。

    📌 "先查再写"永远不是并发安全的。 判断唯一性只有一种可靠方式:数据库唯一索引 + 处理冲突错误。这条在分布式系统里是铁律,不管查的是 Mongo、MySQL 还是 Redis。

    十、PopulateGroupMember:N+1 查询的标准解法

    拉群成员列表时有个绕不开的问题:成员表里只有 user_id 和冗余的 nickname,但要展示头像、真实昵称、在线状态——这些在用户表里。

    最笨的写法:

    for _, m := range members {
    user := getUser(m.UserID) // 500 次 RPC
    }

    500 人的群 = 501 次查询,P99 直接爆炸。

    PopulateGroupMember 的做法是:

  • 收集这一批成员里缺失信息的 userID(nickname 或 face_url 为空的);
  • 一次 GetUsersInfo(userIDs) 批量拉;
  • 回填到结果里,同时异步写回成员表(下次就不用再拉了)。
  • 注意第 3 步:它不只是"补这次的结果",还顺手修数据。这是**读时修复(read repair)**的思路——读到脏数据/缺数据时顺手修,比跑批处理任务简单得多。

    📌 读时修复的适用条件:修复成本低(一次批量写)、漏修不致命(下次读到还会再修)、修的数据量小(只有缺失的那部分)。三个条件都满足时,读时修复比定时任务好得多——它不需要调度、不会漏、没有额外的扫描成本。

    十一、踩坑记录:三个坑,一共 11 小时

    坑一:解散群后,成员列表还在(3 小时)

    现象:调 dismiss_group 之后,get_group_members 还能查到 20 个人。

    定位:调用时 deleteMember 传了 false(默认值)。第八节讲的两种模式——false 只改群状态,成员记录是故意保留的。

    改法:前端/调用方要显式传 true。

    教训:"删除"类接口的布尔参数,一定要在 API 文档里写清默认值和后果。 这个坑不是代码 bug,是接口设计让人误判——deleteMember 这个名字听起来像"要不要删群",实际是"要不要删成员记录"。

    坑二:群转让后管理员变少了(4 小时)

    现象:把群主转给一个管理员,转让后群里有 0 个管理员(原来 2 个)。

    定位:早期版本转让时,旧群主一律降成普通成员(3),新群主升成群主(1)。于是:

    • 旧群主:1 → 3(少了一个"高层")
    • 新群主:2 → 1(又少了一个管理员)

    两个管理员位同时被消耗掉。

    改法:就是第七节那个 newOwner.RoleLevel 参数——按新群主原来的角色决定旧群主降成什么。

    教训:任何"角色变更"都要算一遍"变更前后的角色分布",别只看两个人。

    坑三:成员数显示 501,实际 500 人(4 小时)

    现象:群里显示 501 人,实际成员列表 500 条。

    定位:早期用 $inc 维护 member_count,有一次"邀请 50 人"批量操作部分失败,$inc 加了 50 但实际只成功 49 个,从此永久错 1。

    改法:改成第五节方案三——不维护计数,缓存 count 结果,成员变更就删缓存。

    教训:增量计数一旦错了就没有基线。 能算出来的数就别维护增量。

    十二、面试会怎么问

    追问怎么答(一句话版)
    群成员关系怎么存? 独立中间表 + 三个索引(群+用户唯一、群、用户),不能塞进群文档数组
    为什么不塞数组? 16MB 上限 + 反向查询要全表扫 + 并发改不同成员会互相覆盖
    群成员表为什么冗余昵称? 避免 N+1,500 人群 501 次查询;代价是改昵称要同步所有群
    禁言为什么两层? 群级状态描述群,成员级状态描述关系;合成一层会有 5000 倍写放大
    群人数怎么算? Cache-Aside 缓存 count 结果,不维护 $inc 增量(错了没基线)
    群主转让为什么要事务? 两步改角色,中间失败会得到"没有群主"的不可管理群
    解散群要清多少缓存? 8 类(多维度缓存的代价),建议上代际版本号做 O(1) 失效
    这个项目群模块有什么问题? 8 类缓存逐个删且不原子、deleteMember 参数命名易误判、版本日志用空字符串当哨兵

    十三、小结:一张中间表、两个不变量、三种算法

    • 一张中间表:group_member,一条记录 = 一个"用户在某群的身份"。M:N 关系永远用中间表。
    • 两个不变量:群主不能退群(否则群无人可管)、转让不减少管理员(副作用守恒)。
    • 三种算法:实时 count(慢)、$inc 增量(错了没基线)、Cache-Aside(推荐)。

    这张表建议收藏,做任何"组织/成员"类功能(群、团队、项目成员、权限组)都能套:

    检查项该问什么本项目的答案
    关系存储 中间表还是数组? 中间表 + 三索引
    角色 硬编码还是配置化? 硬编码三级 + 代码 switch
    计数 增量还是缓存结果? 缓存结果(可重算的不维护增量)
    通知 扇出多少、能不能关? 推送层扇出,三层开关
    原子性 哪些操作必须事务? 转让群主、解散群
    缓存 几个维度、怎么失效? 8 类,逐个删(建议代际版本号)
    ID 谁生成、要不要业务对齐? 可指定,业务系统要求对齐

    留个问题,评论区一句话能答: 如果一个 5000 人的群"全员禁言"用的是"给每个成员设置禁言时间"(而不是群级字段),那么大群和小群在写放大上差多少倍?为什么小群里看不出问题?

    下一篇:《Go IM 源码实战(13):会话列表 30 个群,为什么只有 2 个有红点》——会话与未读计数的完整链路。

    作者:利威尔xu | CSDN 专栏《Go IM 源码实战》

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Go IM 源码实战(12):建个 500 人的群,写的是 500 条记录
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!