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
This commit is contained in:
bujinyuan
2026-03-02 18:48:28 +08:00
parent 8ec0261427
commit 2c59500e27
26 changed files with 632 additions and 153 deletions

View File

@@ -0,0 +1,229 @@
# 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. 集成测试 + 文档更新