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

230 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` 包。
需要预定义的接口:
```go
// 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 依赖注入设计
```go
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 需要增加消息事件路由:
```go
// 建议在 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 设计建议
```go
// 会话列表项(首页会话列表展示用)
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 拆分思路
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. 集成测试 + 文档更新