Files
EchoChat/docs/plans/2026-03-02-phase2b-architecture-notes.md
bujinyuan 2c59500e27 fix(phase2a): 代码审查修复 - 8项关键/重要问题
安全修复:
- WebSocket Token 增加 Redis 有效性校验(已登出用户无法建立 WS)

功能修复:
- GetRecommendFriends 改为批量查询,正确返回用户名/昵称/头像
- 上下线通知:OnlineService 通过接口注入获取好友列表推送状态变更
- 管理端在线用户 API 补充用户名信息

代码质量:
- 所有 json.Marshal/Redis 错误增加检查与日志
- ContactController 13 个 endpoint 统一走 handleError 业务错误映射
- 管理端 Controller 补全包注释、函数注释和结构化日志
- 前端 5 个联系人页面 avatar 工具函数抽取到 utils/avatar.js

Made-with: Cursor
2026-03-02 18:48:28 +08:00

8.1 KiB
Raw Permalink Blame History

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/messagesHTTP 发消息)
    │  或
    ├─ WS: im.message.sendWebSocket 发消息)
    │
    ▼
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.goapp/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_idbefore_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/offPhase 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 拆分思路

  1. 设计文档 + 数据库迁移im_conversations, im_conversation_members, im_messages
  2. IM Model + DAO 层
  3. IM Service 层(含接口注入设计)
  4. IM Controller + 路由 + Wire 集成
  5. WebSocket 事件路由机制 + IM 事件处理器
  6. 前台会话列表页 + 聊天页
  7. 前台 chat Store + API 封装
  8. 管理端扩展(可选)
  9. 集成测试 + 文档更新