diff --git a/docs/plans/2026-03-03-phase2b-design.md b/docs/plans/2026-03-03-phase2b-design.md new file mode 100644 index 0000000..5b7f74c --- /dev/null +++ b/docs/plans/2026-03-03-phase2b-design.md @@ -0,0 +1,469 @@ +# Phase 2b 设计文档:即时通讯消息系统(单聊) + +> **状态:** 📋 设计完成,待实施 +> **分支:** `feature/phase2b-instant-messaging` +> **前置依赖:** Phase 2a 全部完成(WebSocket + 联系人管理) +> **架构备忘:** `docs/plans/2026-03-02-phase2b-architecture-notes.md` +> **最后更新:** 2026-03-03 + +--- + +## 一、设计目标 + +基于 Phase 2a 的 WebSocket 基础设施,实现单聊即时通讯核心功能,打通端到端消息收发链路。 + +**核心交付物:** +- 会话管理(创建/列表/置顶/删除/清空) +- 消息收发(全双工 WebSocket,三态 ACK 确认) +- 离线消息推送(WS 连接后服务端主动推送未读摘要) +- 消息撤回(2 分钟内) +- "正在输入"实时提示 +- 未读消息展示(会话 badge + TabBar 总未读数) +- 全局消息搜索 +- 前台 4 个页面:会话列表 + 聊天页 + 会话设置 + 消息搜索 + +**不包含(留待 Phase 2c):** +- 群聊 +- 已读回执 +- 图片/语音/文件消息 +- 管理端消息管理 + +--- + +## 二、需求确认汇总 + +| 决策项 | 选择 | +|--------|------| +| 范围 | 单聊核心,群聊/已读回执放 Phase 2c | +| 消息通道 | 全双工 WebSocket(发送 + 接收都走 WS) | +| 消息类型 | 仅文本,预留 type + extra JSON 字段 | +| 管理端 | 暂不做消息管理 | +| 正在输入 | 包含 | +| 前端页面 | 4 个:会话列表 + 聊天页 + 会话设置 + 消息搜索 | +| 会话创建 | 发第一条消息时自动创建(无空会话) | +| 离线消息 | 服务端 WS 连接后主动推送未读消息 | +| 消息状态 | 三态:发送中 → 已发送(ACK) → 发送失败 | +| 消息撤回 | 支持(2 分钟内) | +| 未读展示 | 会话 badge + TabBar 总未读数 | +| 会话操作 | 置顶 + 删除 + 清空聊天记录 | +| 联系人联动 | 好友详情页"发消息"按钮 + 列表项点击进入聊天 | +| 会话设置 | 对方信息卡片 + 置顶 + 清空 + 删除 | +| 消息搜索 | 全局搜索,结果按会话分组 | +| WS 事件路由 | 事件路由表 `map[string]EventHandler` | +| 分支 | `feature/phase2b-instant-messaging` | + +--- + +## 三、架构方案 + +### 3.1 消息收发核心链路 + +``` +发送方 Client + │ WS: im.message.send { target_user_id, content, client_msg_id } + ▼ +WebSocket Handler(事件路由表分发) + │ → IM MessageHandler.HandleSendMessage() + ▼ +IM Service.SendMessage() + ├── FriendChecker.IsFriend() 校验好友关系 + ├── FindOrCreateConversation() 查找/创建会话 + ├── DB: INSERT im_messages + ├── DB: UPDATE im_conversations (last_message 信息) + ├── DB: UPDATE im_conversation_members (接收方 unread_count +1) + └── Redis: HINCRBY echo:im:unread:{receiverID} convID 1 + │ + ├──→ WS Response: im.message.send.ack { message_id, conversation_id } + │ (返回给发送方,确认消息已存储) + │ + └──→ PubSub.PublishToUser(receiverID, im.message.new) + (推送给接收方,实时展示新消息) +``` + +### 3.2 WS 事件路由表机制 + +Phase 2a 的 `handler.go` 使用 switch-case 分发事件(仅心跳)。Phase 2b 引入事件路由表,支持模块化注册: + +```go +// EventHandler WS 事件处理函数签名 +type EventHandler func(client *Client, msg *Message) + +// Hub 新增字段 +eventHandlers map[string]EventHandler + +// RegisterEvent 注册事件处理器 +func (h *Hub) RegisterEvent(event string, handler EventHandler) + +// DispatchEvent 分发事件(onMessage 中优先查路由表,未命中走原 switch-case) +func (h *Hub) DispatchEvent(client *Client, msg *Message) bool +``` + +IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.recall`、`im.typing.start`、`im.typing.stop`、`im.conversation.sync`。 + +### 3.3 离线消息推送 + +``` +用户 WS 连接成功 + → OnlineService.UserOnline() + → IMService.PushOfflineMessages(userID) + ├── 查询 unread_count > 0 的所有会话 + └── PubSub.PublishToUser: im.conversation.unread 事件 + { conversations: [{ id, unread_count, last_msg_content, last_msg_time, ... }] } +``` + +前端收到 `im.conversation.unread` 后更新会话列表和 TabBar 未读数。 + +### 3.4 消息撤回链路 + +``` +发送方 Client + │ WS: im.message.recall { message_id, conversation_id } + ▼ +IM Service.RecallMessage() + ├── 校验:是否为自己的消息、是否在 2 分钟内 + ├── DB: UPDATE im_messages SET status = 2(已撤回) + ├── DB: UPDATE im_conversations (last_msg_content = "XX 撤回了一条消息") + ├── WS Response: im.message.recall.ack { message_id } + └── PubSub.PublishToUser(receiverID, im.message.recalled) +``` + +### 3.5 跨模块依赖(接口注入模式) + +沿用 Phase 2a 的接口注入标准,避免 `im` 包直接 import `contact` 包: + +```go +// 定义在 app/im/ 包内 +type FriendChecker interface { + IsFriend(ctx context.Context, userID, targetID int64) (bool, error) +} + +type UserInfoGetter interface { + GetUsersByIDs(ctx context.Context, userIDs []int64) ([]model.AuthUser, error) +} +``` + +Wire 注入链:`contact.FriendshipDAO` 隐式实现两个接口。 + +离线消息推送需要 `ws` 模块调用 IM:定义 `OfflineMessagePusher` 接口在 `app/ws/` 中,由 `IMService` 实现。 + +--- + +## 四、数据库设计 + +### 4.1 im_conversations(会话表) + +```sql +CREATE TABLE im_conversations ( + id BIGSERIAL PRIMARY KEY, + type SMALLINT NOT NULL DEFAULT 1, -- 1=单聊(预留 2=群聊) + creator_id BIGINT NOT NULL, -- 创建者 ID + last_message_id BIGINT DEFAULT NULL, -- 最后一条消息 ID + last_msg_content TEXT DEFAULT '', -- 最后消息预览文本 + last_msg_time TIMESTAMP WITH TIME ZONE, -- 最后消息时间 + last_msg_sender_id BIGINT DEFAULT NULL, -- 最后消息发送者 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_im_conversations_updated ON im_conversations(updated_at DESC); +``` + +### 4.2 im_conversation_members(会话成员表) + +```sql +CREATE TABLE im_conversation_members ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES im_conversations(id), + user_id BIGINT NOT NULL, -- 成员用户 ID + is_pinned BOOLEAN DEFAULT FALSE, -- 是否置顶 + is_deleted BOOLEAN DEFAULT FALSE, -- 是否删除会话(软删除,不影响对方) + unread_count INT DEFAULT 0, -- 未读消息数 + last_read_msg_id BIGINT DEFAULT 0, -- 最后已读消息 ID + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + UNIQUE(conversation_id, user_id) +); + +CREATE INDEX idx_im_conv_members_user ON im_conversation_members(user_id, is_deleted); +CREATE INDEX idx_im_conv_members_conv ON im_conversation_members(conversation_id); +``` + +### 4.3 im_messages(消息表) + +```sql +CREATE TABLE im_messages ( + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES im_conversations(id), + sender_id BIGINT NOT NULL, -- 发送者 ID + type SMALLINT NOT NULL DEFAULT 1, -- 1=文本(预留 2=图片 3=语音...) + content TEXT NOT NULL, -- 消息内容 + extra JSONB DEFAULT NULL, -- 扩展数据(预留) + status SMALLINT NOT NULL DEFAULT 1, -- 1=正常 2=已撤回 3=已删除 + client_msg_id VARCHAR(64) DEFAULT '', -- 客户端消息唯一 ID(幂等用) + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 游标分页核心索引 +CREATE INDEX idx_im_messages_conv_time ON im_messages(conversation_id, created_at DESC); +CREATE INDEX idx_im_messages_conv_id ON im_messages(conversation_id, id DESC); +-- 全局消息搜索索引 +CREATE INDEX idx_im_messages_content_search ON im_messages USING gin(to_tsvector('simple', content)); +``` + +### 4.4 单聊会话去重 + +Service 层通过查询 `im_conversation_members` 判断两人间是否已存在单聊会话: + +```sql +SELECT cm1.conversation_id FROM im_conversation_members cm1 +JOIN im_conversation_members cm2 ON cm1.conversation_id = cm2.conversation_id +JOIN im_conversations c ON c.id = cm1.conversation_id +WHERE cm1.user_id = ? AND cm2.user_id = ? AND c.type = 1 +LIMIT 1 +``` + +### 4.5 Redis 数据结构 + +| Key | 类型 | 说明 | +|-----|------|------| +| `echo:im:unread:{user_id}` | HASH | 每个会话的未读数 `{ 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}` 求和 + +--- + +## 五、WebSocket 事件协议 + +### 5.1 客户端 → 服务端(发送类) + +| 事件 | 说明 | Data 字段 | +|------|------|-----------| +| `im.message.send` | 发送消息 | `{ target_user_id, content, type, client_msg_id }` | +| `im.message.recall` | 撤回消息 | `{ message_id, conversation_id }` | +| `im.typing.start` | 开始输入 | `{ conversation_id }` | +| `im.typing.stop` | 停止输入 | `{ conversation_id }` | +| `im.conversation.sync` | 请求同步离线消息 | `{ conversation_id, last_msg_id }` | + +### 5.2 服务端 → 客户端(ACK 响应) + +使用现有 `Response` 结构体(`event` + `.ack` 后缀): + +| 事件 | 说明 | Data 字段 | +|------|------|-----------| +| `im.message.send.ack` | 发送成功确认 | `{ message_id, conversation_id, created_at }` | +| `im.message.recall.ack` | 撤回成功确认 | `{ message_id }` | + +### 5.3 服务端 → 客户端(推送类) + +使用现有 `PushMessage` 结构体: + +| 事件 | 说明 | Data 字段 | +|------|------|-----------| +| `im.message.new` | 新消息推送 | `{ message_id, conversation_id, sender_id, sender_name, sender_avatar, type, content, created_at }` | +| `im.message.recalled` | 消息被撤回 | `{ message_id, conversation_id, sender_id }` | +| `im.typing.notify` | 正在输入通知 | `{ conversation_id, user_id, is_typing }` | +| `im.conversation.unread` | 离线未读摘要 | `{ conversations: [{ id, type, unread_count, last_msg_content, last_msg_time, peer_user_id, peer_nickname, peer_avatar }] }` | + +--- + +## 六、REST API 设计(查询类) + +所有查询类操作仍走 REST,前缀 `/api/v1/im/`: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/conversations` | 获取会话列表(含未读数、最后消息、对方信息) | +| GET | `/conversations/:id/messages` | 获取历史消息(游标分页:`before_id` + `limit`) | +| PUT | `/conversations/:id/pin` | 置顶/取消置顶 | +| DELETE | `/conversations/:id` | 删除会话(软删除) | +| DELETE | `/conversations/:id/messages` | 清空聊天记录 | +| PUT | `/conversations/:id/read` | 标记会话已读(清零未读数 + 清 Redis) | +| GET | `/messages/search?keyword=xxx` | 全局消息搜索(结果按会话分组) | + +共 **7 个 REST API**,均需 JWT 认证。 + +--- + +## 七、后端模块设计 + +### 7.1 IM 模块目录结构 + +``` +app/im/ +├── controller/ +│ └── im_controller.go # REST API 控制器 +├── service/ +│ └── im_service.go # 核心业务逻辑 +├── dao/ +│ ├── conversation_dao.go # 会话 CRUD +│ └── message_dao.go # 消息 CRUD + 搜索 +├── model/ +│ ├── conversation.go # im_conversations GORM 模型 +│ ├── conversation_member.go # im_conversation_members 模型 +│ └── message.go # im_messages 模型 +├── handler/ +│ └── message_handler.go # WS 事件处理器 +├── router.go # REST 路由注册 +└── provider.go # Wire ProviderSet +``` + +### 7.2 依赖注入设计 + +```go +type IMService struct { + conversationDAO *dao.ConversationDAO + messageDAO *dao.MessageDAO + pubsub *ws.PubSub // 复用 Phase 2a + friendChecker FriendChecker // 接口注入 → FriendshipDAO.IsFriend + userInfoGetter UserInfoGetter // 接口注入 → FriendshipDAO.GetUsersByIDs +} +``` + +Wire 注入链:`contact.FriendshipDAO` → 隐式实现 `FriendChecker` + `UserInfoGetter`。 + +### 7.3 IMService 核心方法 + +| 方法 | 说明 | +|------|------| +| `SendMessage(ctx, senderID, targetUserID, content, clientMsgID)` | 发送消息(含创建会话、写库、推送) | +| `RecallMessage(ctx, userID, messageID, conversationID)` | 撤回消息 | +| `GetConversations(ctx, userID)` | 获取会话列表 | +| `GetMessages(ctx, userID, conversationID, beforeID, limit)` | 获取历史消息(游标分页) | +| `PinConversation(ctx, userID, conversationID, isPinned)` | 置顶/取消置顶 | +| `DeleteConversation(ctx, userID, conversationID)` | 删除会话(软删除) | +| `ClearMessages(ctx, userID, conversationID)` | 清空聊天记录 | +| `MarkAsRead(ctx, userID, conversationID)` | 标记已读 | +| `SearchMessages(ctx, userID, keyword)` | 全局搜索 | +| `PushOfflineMessages(ctx, userID)` | 推送离线未读摘要 | +| `FindOrCreateConversation(ctx, userID, targetUserID)` | 查找/创建单聊会话 | + +--- + +## 八、前端设计 + +### 8.1 页面结构 + +``` +frontend/src/pages/chat/ +├── index.vue # 会话列表页(消息 Tab 主页面) +├── conversation.vue # 聊天页(消息收发界面) +├── settings.vue # 会话设置页 +└── search.vue # 消息搜索页 +``` + +### 8.2 Store 设计(store/chat.js) + +**状态:** +- `conversations`:会话列表(按置顶 + 最后消息时间排序) +- `currentConversation`:当前打开的会话 +- `messages`:当前会话的消息列表(内存缓存最新 N 条) +- `totalUnread`:总未读消息数 +- `typingUsers`:正在输入的用户 Map + +**Actions:** +- `loadConversations()`:REST 拉取会话列表 +- `sendMessage(targetUserId, content)`:WS 发送消息 +- `loadHistory(conversationId, beforeId)`:REST 拉取历史消息 +- `markAsRead(conversationId)`:标记已读 + 清 Redis +- `recallMessage(messageId, conversationId)`:撤回消息 +- `pinConversation(id, isPinned)`:置顶/取消置顶 +- `deleteConversation(id)`:删除会话 +- `clearMessages(id)`:清空聊天记录 +- `searchMessages(keyword)`:全局搜索 + +**WS 事件监听:** +- `im.message.new` → 更新 conversations + messages + totalUnread +- `im.message.recalled` → 更新消息状态为"已撤回" +- `im.typing.notify` → 更新 typingUsers +- `im.conversation.unread` → 初始化未读数 + 会话列表 + +### 8.3 API 封装(api/chat.js) + +封装 7 个 REST API 的请求函数。 + +### 8.4 页面功能描述 + +**会话列表页 (index.vue)**: +- 顶部搜索栏(跳转消息搜索页) +- 会话列表:头像 + 昵称 + 最后消息预览 + 时间 + 未读 badge + 置顶标记 +- 长按操作菜单:置顶/取消置顶、删除会话、清空聊天记录 +- 空状态提示("暂无消息,去找好友聊聊吧") + +**聊天页 (conversation.vue)**: +- 顶部导航:对方昵称 + "正在输入…"指示 + 设置入口 +- 消息列表:支持向上滚动加载历史消息 +- 消息气泡:自己(右侧蓝)/ 对方(左侧灰) +- 消息状态图标:发送中(loading) / 已发送(check) / 失败(retry 可重发) +- 长按消息:撤回(2 分钟内自己的消息) +- 底部输入区:文本框 + 发送按钮 +- 输入时自动触发 typing 事件(节流 3s) + +**会话设置页 (settings.vue)**: +- 对方信息卡片(头像 + 昵称 + 用户名) +- 置顶聊天开关 +- 清空聊天记录按钮(二次确认) +- 删除会话按钮(二次确认) + +**消息搜索页 (search.vue)**: +- 搜索输入框(防抖 300ms) +- 搜索结果按会话分组展示(每组:会话名 + 匹配消息摘要列表) +- 点击结果跳转到对应聊天页 + +所有前端页面使用 **ui-ux-pro-max** 技能包进行设计和开发。 + +--- + +## 九、联系人模块改造 + +### 9.1 好友详情页 (contact/detail.vue) + +将现有 `sendMessage()` 占位方法改为跳转聊天页: + +```javascript +const sendMessage = () => { + uni.navigateTo({ url: `/pages/chat/conversation?userId=${friend.value.user_id}` }) +} +``` + +### 9.2 好友列表页 (contact/index.vue) + +好友列表项添加点击事件,点击直接跳转到聊天页。 + +### 9.3 CustomTabBar 改造 + +- 新增消息 Tab(跳转 `/pages/chat/index`) +- 消息 Tab 添加未读数 badge(从 `chatStore.totalUnread` 读取) + +--- + +## 十、关键设计决策 + +| # | 决策 | 选择 | 理由 | +|---|------|------|------| +| 1 | 事件路由表 vs switch-case | 事件路由表 | 模块化注册,便于后续 Phase 扩展 | +| 2 | 发送走 WS vs REST | 全双工 WS | 实时体验更好,延迟更低 | +| 3 | 离线消息 | 服务端主动推送 | 用户体验好,无需手动刷新 | +| 4 | 历史消息分页 | 游标分页 (before_id) | 避免 OFFSET 性能劣化 | +| 5 | 消息幂等 | client_msg_id | 防止网络重试导致重复消息 | +| 6 | 单聊去重 | Service 层查询 | 灵活,不依赖数据库 unique constraint | +| 7 | 消息撤回时限 | 2 分钟 | 参考微信等主流 IM | + +--- + +## 十一、Task 拆分(10 个 Task) + +| Task | 描述 | 涉及端 | +|------|------|--------| +| Task 0 | 设计文档 + 新分支 + 数据库表迁移 | 后端 | +| Task 1 | WS 事件路由表机制(改造 pkg/ws + app/ws/handler) | 后端 | +| Task 2 | IM Model + DAO 层 | 后端 | +| Task 3 | IM Service 核心业务(发消息、撤回、会话管理) | 后端 | +| Task 4 | IM WS 事件处理器 + 离线消息推送 | 后端 | +| Task 5 | IM REST Controller + Router + Wire 集成 | 后端 | +| Task 6 | 前台 chat Store + API 封装 + WS 事件集成 | 前端 | +| Task 7 | 前台会话列表页 + 聊天页 | 前端 | +| Task 8 | 前台会话设置页 + 消息搜索页 + 联系人模块改造 | 前端 | +| Task 9 | 集成测试 + 文档更新 + 代码审查 | 全端 |