安全修复: - WebSocket Token 增加 Redis 有效性校验(已登出用户无法建立 WS) 功能修复: - GetRecommendFriends 改为批量查询,正确返回用户名/昵称/头像 - 上下线通知:OnlineService 通过接口注入获取好友列表推送状态变更 - 管理端在线用户 API 补充用户名信息 代码质量: - 所有 json.Marshal/Redis 错误增加检查与日志 - ContactController 13 个 endpoint 统一走 handleError 业务错误映射 - 管理端 Controller 补全包注释、函数注释和结构化日志 - 前端 5 个联系人页面 avatar 工具函数抽取到 utils/avatar.js Made-with: Cursor
8.1 KiB
Phase 2b 架构建议备忘录:即时通讯消息系统
创建日期: 2026-03-02 状态: 📋 待设计(供 Phase 2b 设计阶段参考) 前置依赖: Phase 2a 全部完成(WebSocket + 联系人管理)
一、Phase 2a 经验总结与架构教训
1.1 接口注入模式(Interface Injection)
Phase 2a 后期修复中,ws.OnlineService 需要获取好友列表来推送上下线通知,但不应直接依赖 contact 包。通过定义 FriendIDsGetter 接口,由 FriendshipDAO 隐式实现,在 Wire 中注入——这是跨模块通信的标准模式。
Phase 2b 建议: IM 模块同样需要获取好友/联系人数据(如验证单聊权限、获取会话成员信息),必须遵循相同的接口注入模式,避免 im 包直接 import contact 包。
需要预定义的接口:
// im 模块可能需要的外部依赖接口
type FriendChecker interface {
IsFriend(ctx context.Context, userID, targetID int64) (bool, error)
}
type UserInfoGetter interface {
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]UserBasicInfo, error)
}
1.2 批量查询模式
Phase 2a 的 GetRecommendFriends 最初对每个候选人单独查询用户信息,性能极差。修复后使用 GetUsersByIDs 一次批量查询 + Map 映射。
Phase 2b 建议: IM 消息列表中需要展示发送者信息(头像、昵称),必须使用批量查询模式:
- 收集一页消息的所有
sender_id - 去重后一次 IN 查询获取用户信息
- 构建
map[int64]UserBasicInfo映射 - 按消息顺序填充发送者信息
1.3 Admin 端查询模式
OnlineManageService 最初只返回 []int64,缺少用户名导致管理端无法展示。修复后注入 *gorm.DB 查询用户表补充信息。
Phase 2b 建议: 管理端的消息管理/会话管理 API 从一开始就设计为返回完整信息的 DTO,不要只返回 ID。
二、IM 模块架构建议
2.1 模块结构
app/im/
├── controller/
│ └── im_controller.go # REST API 控制器
├── service/
│ └── im_service.go # 业务逻辑(会话管理、消息收发)
├── dao/
│ ├── conversation_dao.go # 会话 DAO
│ └── message_dao.go # 消息 DAO
├── model/
│ ├── conversation.go # im_conversations 模型
│ ├── conversation_member.go # im_conversation_members 模型
│ └── message.go # im_messages 模型
├── handler/
│ └── message_handler.go # WebSocket 消息事件处理器
├── router.go # 路由注册
└── provider.go # Wire Provider Set
2.2 消息收发链路设计
发送者 Client
│
├─ REST: POST /api/v1/im/conversations/:id/messages(HTTP 发消息)
│ 或
├─ WS: im.message.send(WebSocket 发消息)
│
▼
IM Service
│── 权限校验(是否为会话成员)
│── 消息写入 PostgreSQL (im_messages)
│── 更新会话 last_message 信息
│── 更新 Redis 未读计数 (HINCRBY echo:im:unread:{member_id} conv_id 1)
│
▼
PubSub.PublishToUser(复用 Phase 2a 的 Pub/Sub 基础设施)
│── 推送给会话内所有在线成员
│── 事件: im.message.new
│
▼
接收者 Client(实时收到消息推送)
2.3 依赖注入设计
type IMService struct {
conversationDAO *dao.ConversationDAO
messageDAO *dao.MessageDAO
pubsub *ws.PubSub // 复用 Phase 2a 的 Pub/Sub
friendChecker FriendChecker // 接口注入,contact.FriendshipDAO 实现
userInfoGetter UserInfoGetter // 接口注入,批量获取用户信息
}
Wire 注入链:
FriendshipDAO→ 隐式实现FriendChecker(已有IsFriend方法)FriendshipDAO→ 隐式实现UserInfoGetter(已有GetUsersByIDs方法)
2.4 WebSocket 事件处理器
Phase 2a 的 WebSocket Handler 只处理心跳和连接管理。Phase 2b 需要增加消息事件路由:
// 建议在 app/ws/handler.go 中增加事件分发机制
// 或在 app/im/handler/ 下实现 IM 事件处理器,注册到 Hub
// 需要处理的 WS 事件
// im.message.send → 发送消息
// im.message.read → 标记已读
// im.typing.start → 正在输入
// im.typing.stop → 停止输入
建议方案: 在 pkg/ws/hub.go 或 app/ws/handler.go 中实现事件路由表(map[string]EventHandler),各模块注册自己的事件处理器,避免 handler.go 膨胀成巨型文件。
三、数据库注意事项
3.1 单聊会话去重
单聊会话应保证两个用户之间只有一个会话。建议方案:
- 创建单聊时,用两个 user_id 的较小值和较大值组合查询是否已存在
- 或增加唯一约束 + 规范化存储(小 ID 在前)
3.2 消息分页查询
im_messages 将是数据量最大的表。已有索引 idx_im_messages_conv_time (conversation_id, created_at DESC)。
分页建议:
- 使用游标分页(
WHERE created_at < ? AND conversation_id = ? ORDER BY created_at DESC LIMIT ?)而非 OFFSET 分页 - 前端传
before_msg_id或before_time参数
3.3 未读消息计数
Redis HASH echo:im:unread:{user_id} → {conv_id: count}
- 收到新消息:
HINCRBY echo:im:unread:{user_id} {conv_id} 1 - 标记已读:
HDEL echo:im:unread:{user_id} {conv_id} - 获取总未读数:
HVALS echo:im:unread:{user_id}求和
四、DTO 设计建议
// 会话列表项(首页会话列表展示用)
type ConversationItem struct {
ID int64 `json:"id"`
Type int `json:"type"` // 1=单聊, 2=群聊
Name string `json:"name"` // 群聊名称 / 对方昵称
Avatar string `json:"avatar"` // 群头像 / 对方头像
LastMessage string `json:"last_message"` // 最后一条消息预览
LastMessageTime string `json:"last_message_time"` // 最后消息时间
UnreadCount int `json:"unread_count"` // 未读消息数
IsPinned bool `json:"is_pinned"` // 是否置顶
}
// 消息项
type MessageItem struct {
ID int64 `json:"id"`
SenderID int64 `json:"sender_id"`
SenderName string `json:"sender_name"` // 批量查询填充
SenderAvatar string `json:"sender_avatar"` // 批量查询填充
Type int `json:"type"`
Content string `json:"content"`
Extra interface{} `json:"extra,omitempty"`
Status int `json:"status"`
CreatedAt string `json:"created_at"`
}
五、前端注意事项
5.1 WebSocket 事件集成
前台 services/websocket.js 已支持事件监听(on/off),Phase 2b 需要注册新事件:
im.message.new→ 更新会话列表 + 未读数 + 当前聊天窗口im.typing.start/stop→ 显示"正在输入..."
5.2 Store 设计
store/
├── chat.js # 会话列表、当前会话、消息缓存(新增)
└── ...existing stores
消息缓存策略:
- 每个会话缓存最新 N 条消息在内存中
- 向上翻页时 REST 拉取历史消息
- 新消息通过 WS 推送自动追加
六、管理端扩展建议
Phase 2b 管理端可暂不实现消息管理,但如果实现,参考 Phase 2a 的模式:
admin/service/im_manage_service.go- 注入
*gorm.DB查询 im_messages + auth_users - 返回包含发送者用户名的完整 DTO(不要只返回 ID)
七、Phase 2b 建议的 Task 拆分思路
- 设计文档 + 数据库迁移(im_conversations, im_conversation_members, im_messages)
- IM Model + DAO 层
- IM Service 层(含接口注入设计)
- IM Controller + 路由 + Wire 集成
- WebSocket 事件路由机制 + IM 事件处理器
- 前台会话列表页 + 聊天页
- 前台 chat Store + API 封装
- 管理端扩展(可选)
- 集成测试 + 文档更新