- 设计文档新增 Mermaid 消息收发时序图 - IMService 依赖注入补充 *redis.Client(未读数 HASH 操作) - 实施计划同步更新 Redis 依赖和 unreadKeyPrefix Made-with: Cursor
866 lines
26 KiB
Markdown
866 lines
26 KiB
Markdown
# Phase 2b 即时通讯消息系统(单聊)实施计划
|
||
|
||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||
|
||
**Goal:** 基于 Phase 2a WebSocket 基础设施,实现单聊即时通讯核心功能
|
||
|
||
**Architecture:** 新建 `app/im/` 模块(MVC 分层),扩展 `pkg/ws/` 事件路由,前端新增 chat Store + 4 个页面
|
||
|
||
**Tech Stack:** Go (Gin + GORM + Wire) / Redis / PostgreSQL / Vue 3 (uni-app + Pinia) / WebSocket
|
||
|
||
**Design Doc:** `docs/plans/2026-03-03-phase2b-design.md`
|
||
|
||
---
|
||
|
||
## Task 0: 设计文档 + 新分支 + 数据库表迁移
|
||
|
||
**Files:**
|
||
- 已完成: `docs/plans/2026-03-03-phase2b-design.md`
|
||
- Modify: `backend/go-service/cmd/server/main.go`(AutoMigrate 新表)
|
||
|
||
**Step 1: 创建新分支**
|
||
|
||
```bash
|
||
cd /Users/bojinyuan/Documents/workspace/webrtc/EchoChat
|
||
git checkout -b feature/phase2b-instant-messaging
|
||
```
|
||
|
||
**Step 2: 创建 IM 模型文件**
|
||
|
||
Create: `backend/go-service/app/im/model/conversation.go`
|
||
|
||
```go
|
||
package model
|
||
|
||
import "time"
|
||
|
||
type Conversation struct {
|
||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||
Type int `json:"type" gorm:"not null;default:1;comment:1=单聊 2=群聊"`
|
||
CreatorID int64 `json:"creator_id" gorm:"not null"`
|
||
LastMessageID *int64 `json:"last_message_id"`
|
||
LastMsgContent string `json:"last_msg_content" gorm:"type:text;default:''"`
|
||
LastMsgTime *time.Time `json:"last_msg_time"`
|
||
LastMsgSenderID *int64 `json:"last_msg_sender_id"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (Conversation) TableName() string { return "im_conversations" }
|
||
```
|
||
|
||
Create: `backend/go-service/app/im/model/conversation_member.go`
|
||
|
||
```go
|
||
package model
|
||
|
||
import "time"
|
||
|
||
type ConversationMember struct {
|
||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||
ConversationID int64 `json:"conversation_id" gorm:"not null;uniqueIndex:idx_conv_user"`
|
||
UserID int64 `json:"user_id" gorm:"not null;uniqueIndex:idx_conv_user"`
|
||
IsPinned bool `json:"is_pinned" gorm:"default:false"`
|
||
IsDeleted bool `json:"is_deleted" gorm:"default:false"`
|
||
UnreadCount int `json:"unread_count" gorm:"default:0"`
|
||
LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"`
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
func (ConversationMember) TableName() string { return "im_conversation_members" }
|
||
```
|
||
|
||
Create: `backend/go-service/app/im/model/message.go`
|
||
|
||
```go
|
||
package model
|
||
|
||
import "time"
|
||
|
||
type Message struct {
|
||
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||
ConversationID int64 `json:"conversation_id" gorm:"not null;index:idx_msg_conv_time;index:idx_msg_conv_id"`
|
||
SenderID int64 `json:"sender_id" gorm:"not null"`
|
||
Type int `json:"type" gorm:"not null;default:1;comment:1=文本"`
|
||
Content string `json:"content" gorm:"type:text;not null"`
|
||
Extra *string `json:"extra" gorm:"type:jsonb"`
|
||
Status int `json:"status" gorm:"not null;default:1;comment:1=正常 2=已撤回 3=已删除"`
|
||
ClientMsgID string `json:"client_msg_id" gorm:"size:64;default:''"`
|
||
CreatedAt time.Time `json:"created_at" gorm:"index:idx_msg_conv_time"`
|
||
}
|
||
|
||
func (Message) TableName() string { return "im_messages" }
|
||
```
|
||
|
||
**Step 3: 在 main.go 添加 AutoMigrate**
|
||
|
||
在 `cmd/server/main.go` 中(Wire 初始化之后),新增 IM 表 AutoMigrate:
|
||
|
||
```go
|
||
// 在 app, err := provider.InitializeApp(cfg) 之后添加
|
||
import imModel "github.com/echochat/backend/app/im/model"
|
||
|
||
if err := app.DB.AutoMigrate(
|
||
&imModel.Conversation{},
|
||
&imModel.ConversationMember{},
|
||
&imModel.Message{},
|
||
); err != nil {
|
||
logs.Fatal(ctx, "main", "IM 表迁移失败", zap.Error(err))
|
||
}
|
||
```
|
||
|
||
**Step 4: 创建 Redis 索引 SQL(手动执行)**
|
||
|
||
全文搜索索引需手动执行(GORM AutoMigrate 不支持 GIN 索引):
|
||
|
||
```sql
|
||
CREATE INDEX IF NOT EXISTS idx_im_messages_content_search
|
||
ON im_messages USING gin(to_tsvector('simple', content));
|
||
```
|
||
|
||
**Step 5: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
Expected: 编译成功
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(im): Task 0 - IM 模型定义 + 数据库表迁移"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: WS 事件路由表机制
|
||
|
||
**Files:**
|
||
- Modify: `backend/go-service/pkg/ws/hub.go`(添加事件路由表)
|
||
- Modify: `backend/go-service/pkg/ws/message.go`(添加 MessageHandler 类型导出)
|
||
- Modify: `backend/go-service/app/ws/handler.go`(onMessage 优先查路由表)
|
||
|
||
**Step 1: 在 hub.go 添加事件路由表**
|
||
|
||
Hub 新增字段和方法:
|
||
|
||
```go
|
||
// EventHandler WS 事件处理函数签名
|
||
type EventHandler func(client *Client, msg *Message)
|
||
|
||
// Hub 新增字段
|
||
type Hub struct {
|
||
// ...现有字段
|
||
eventHandlers map[string]EventHandler
|
||
ehMu sync.RWMutex // 保护 eventHandlers
|
||
}
|
||
|
||
// NewHub 修改:初始化 eventHandlers
|
||
func NewHub() *Hub {
|
||
return &Hub{
|
||
clients: make(map[int64]*Client),
|
||
register: make(chan *Client, 256),
|
||
unregister: make(chan *Client, 256),
|
||
stopCh: make(chan struct{}),
|
||
eventHandlers: make(map[string]EventHandler),
|
||
}
|
||
}
|
||
|
||
// RegisterEvent 注册事件处理器(启动时调用,非热路径)
|
||
func (h *Hub) RegisterEvent(event string, handler EventHandler) {
|
||
h.ehMu.Lock()
|
||
defer h.ehMu.Unlock()
|
||
h.eventHandlers[event] = handler
|
||
}
|
||
|
||
// DispatchEvent 分发事件到注册的处理器
|
||
// 返回 true 表示已处理,false 表示无匹配处理器
|
||
func (h *Hub) DispatchEvent(client *Client, msg *Message) bool {
|
||
h.ehMu.RLock()
|
||
handler, ok := h.eventHandlers[msg.Event]
|
||
h.ehMu.RUnlock()
|
||
if !ok {
|
||
return false
|
||
}
|
||
handler(client, msg)
|
||
return true
|
||
}
|
||
```
|
||
|
||
**Step 2: 修改 handler.go 的 onMessage**
|
||
|
||
在 `createReadHandler` 的 switch 之前,先尝试路由表分发:
|
||
|
||
```go
|
||
func (h *Handler) createReadHandler(userID int64) ws.MessageHandler {
|
||
return func(client *ws.Client, msg *ws.Message) {
|
||
// 先尝试事件路由表
|
||
if h.hub.DispatchEvent(client, msg) {
|
||
return
|
||
}
|
||
|
||
// 内置事件 fallback
|
||
switch msg.Event {
|
||
case "heartbeat":
|
||
// ...现有逻辑
|
||
default:
|
||
// ...现有逻辑
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(ws): Task 1 - Hub 事件路由表机制"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: IM DAO 层
|
||
|
||
**Files:**
|
||
- Create: `backend/go-service/app/im/dao/conversation_dao.go`
|
||
- Create: `backend/go-service/app/im/dao/message_dao.go`
|
||
|
||
**Step 1: 创建 ConversationDAO**
|
||
|
||
Create: `backend/go-service/app/im/dao/conversation_dao.go`
|
||
|
||
核心方法:
|
||
- `FindPrivateConversation(ctx, userID, targetUserID)` — 查找两人的单聊会话
|
||
- `CreateWithMembers(ctx, conv, memberIDs)` — 事务创建会话 + 成员
|
||
- `GetUserConversations(ctx, userID)` — 获取用户会话列表(JOIN members + conversations)
|
||
- `GetMember(ctx, conversationID, userID)` — 获取成员信息
|
||
- `UpdateMember(ctx, member)` — 更新成员信息(置顶、未读数等)
|
||
- `SoftDeleteMember(ctx, conversationID, userID)` — 软删除会话
|
||
- `UpdateLastMessage(ctx, conversationID, msgID, content, senderID, time)` — 更新最后消息
|
||
- `IncrementUnread(ctx, conversationID, userID)` — 未读数 +1
|
||
- `ClearUnread(ctx, conversationID, userID)` — 清零未读
|
||
- `GetUnreadConversations(ctx, userID)` — 获取有未读的会话列表
|
||
|
||
**Step 2: 创建 MessageDAO**
|
||
|
||
Create: `backend/go-service/app/im/dao/message_dao.go`
|
||
|
||
核心方法:
|
||
- `Create(ctx, message)` — 写入消息
|
||
- `GetByID(ctx, id)` — 查询单条消息
|
||
- `GetByConversation(ctx, conversationID, beforeID, limit)` — 游标分页查消息
|
||
- `UpdateStatus(ctx, id, status)` — 更新消息状态(撤回)
|
||
- `DeleteByConversation(ctx, conversationID)` — 清空会话消息
|
||
- `SearchMessages(ctx, userID, keyword, limit)` — 全局搜索(JOIN members 确保权限)
|
||
- `FindByClientMsgID(ctx, conversationID, clientMsgID)` — 幂等查询
|
||
|
||
**Step 3: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
**Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(im): Task 2 - IM DAO 层(会话 + 消息)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: IM Service 核心业务
|
||
|
||
**Files:**
|
||
- Create: `backend/go-service/app/im/service/im_service.go`
|
||
- Create: `backend/go-service/app/im/service/interfaces.go`(接口定义)
|
||
|
||
**Step 1: 创建接口定义文件**
|
||
|
||
Create: `backend/go-service/app/im/service/interfaces.go`
|
||
|
||
```go
|
||
package service
|
||
|
||
import (
|
||
"context"
|
||
authModel "github.com/echochat/backend/app/auth/model"
|
||
)
|
||
|
||
// FriendChecker 好友关系校验接口(contact.FriendshipDAO 隐式实现)
|
||
type FriendChecker interface {
|
||
IsFriend(ctx context.Context, userID, targetID int64) (bool, error)
|
||
}
|
||
|
||
// UserInfoGetter 用户信息批量查询接口(contact.FriendshipDAO 隐式实现)
|
||
type UserInfoGetter interface {
|
||
GetUsersByIDs(ctx context.Context, userIDs []int64) ([]authModel.User, error)
|
||
}
|
||
|
||
// Redis Key 前缀(未读数 HASH)
|
||
const unreadKeyPrefix = "echo:im:unread:"
|
||
```
|
||
|
||
**Step 2: 创建 IMService**
|
||
|
||
Create: `backend/go-service/app/im/service/im_service.go`
|
||
|
||
IMService 注入:`ConversationDAO`、`MessageDAO`、`*ws.PubSub`、`*redis.Client`、`FriendChecker`、`UserInfoGetter`
|
||
|
||
注意:`*redis.Client` 用于操作未读数 Redis HASH(`echo:im:unread:{user_id}`)
|
||
|
||
核心方法(参考设计文档第七节):
|
||
- `SendMessage(ctx, senderID, targetUserID, content, msgType, clientMsgID)` — 核心链路:校验好友 → 查找/创建会话 → 写消息 → 更新 last_message → 增加未读 → 推送
|
||
- `RecallMessage(ctx, userID, messageID, conversationID)` — 校验 2 分钟 + 更新状态 + 推送
|
||
- `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)` — 标记已读 + 清 Redis
|
||
- `SearchMessages(ctx, userID, keyword)` — 全局搜索
|
||
- `PushOfflineMessages(ctx, userID)` — 查未读会话 → 推送 im.conversation.unread
|
||
- `FindOrCreateConversation(ctx, userID, targetUserID)` — 内部方法
|
||
|
||
DTO 定义放在 `app/dto/im_dto.go`(新建):
|
||
- `ConversationItem` — 会话列表项
|
||
- `MessageItem` — 消息项
|
||
- `SearchResult` — 搜索结果(按会话分组)
|
||
|
||
**Step 3: 创建 IM DTO**
|
||
|
||
Create: `backend/go-service/app/dto/im_dto.go`
|
||
|
||
```go
|
||
package dto
|
||
|
||
type ConversationItem struct {
|
||
ID int64 `json:"id"`
|
||
Type int `json:"type"`
|
||
PeerUserID int64 `json:"peer_user_id"`
|
||
PeerNickname string `json:"peer_nickname"`
|
||
PeerAvatar string `json:"peer_avatar"`
|
||
PeerUsername string `json:"peer_username"`
|
||
LastMsgContent string `json:"last_msg_content"`
|
||
LastMsgTime string `json:"last_msg_time"`
|
||
LastMsgSenderID int64 `json:"last_msg_sender_id"`
|
||
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"`
|
||
Status int `json:"status"`
|
||
ClientMsgID string `json:"client_msg_id"`
|
||
CreatedAt string `json:"created_at"`
|
||
}
|
||
|
||
type MessageSearchGroup struct {
|
||
ConversationID int64 `json:"conversation_id"`
|
||
PeerNickname string `json:"peer_nickname"`
|
||
PeerAvatar string `json:"peer_avatar"`
|
||
Messages []MessageItem `json:"messages"`
|
||
}
|
||
```
|
||
|
||
**Step 4: 添加 IM 常量**
|
||
|
||
Create: `backend/go-service/app/constants/im_constants.go`
|
||
|
||
```go
|
||
package constants
|
||
|
||
const (
|
||
ConversationTypePrivate = 1
|
||
ConversationTypeGroup = 2
|
||
|
||
MessageTypeText = 1
|
||
|
||
MessageStatusNormal = 1
|
||
MessageStatusRecalled = 2
|
||
MessageStatusDeleted = 3
|
||
|
||
MessageRecallTimeLimit = 2 * 60 // 撤回时限(秒)
|
||
)
|
||
```
|
||
|
||
**Step 5: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(im): Task 3 - IM Service + DTO + 常量定义"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: IM WS 事件处理器 + 离线消息推送
|
||
|
||
**Files:**
|
||
- Create: `backend/go-service/app/im/handler/message_handler.go`
|
||
- Modify: `backend/go-service/app/ws/handler.go`(离线消息推送接口 + 连接回调)
|
||
- Modify: `backend/go-service/app/ws/online_service.go`(添加 OfflineMessagePusher 接口)
|
||
|
||
**Step 1: 创建 IM 事件处理器**
|
||
|
||
Create: `backend/go-service/app/im/handler/message_handler.go`
|
||
|
||
```go
|
||
package handler
|
||
|
||
// MessageHandler IM 模块的 WebSocket 事件处理器
|
||
// 负责处理:im.message.send、im.message.recall、im.typing.start/stop、im.conversation.sync
|
||
|
||
type MessageHandler struct {
|
||
imService *service.IMService
|
||
hub *ws.Hub
|
||
}
|
||
|
||
// RegisterEvents 向 Hub 注册 IM 事件处理器
|
||
func (h *MessageHandler) RegisterEvents(hub *ws.Hub) {
|
||
hub.RegisterEvent("im.message.send", h.HandleSendMessage)
|
||
hub.RegisterEvent("im.message.recall", h.HandleRecallMessage)
|
||
hub.RegisterEvent("im.typing.start", h.HandleTypingStart)
|
||
hub.RegisterEvent("im.typing.stop", h.HandleTypingStop)
|
||
hub.RegisterEvent("im.conversation.sync", h.HandleConversationSync)
|
||
}
|
||
```
|
||
|
||
每个 Handler 方法:解析 msg.Data → 调用 IMService → 返回 ACK Response
|
||
|
||
**Step 2: 添加离线消息推送接口**
|
||
|
||
在 `app/ws/online_service.go` 中定义接口:
|
||
|
||
```go
|
||
// OfflineMessagePusher 离线消息推送接口(由 IM Service 实现)
|
||
type OfflineMessagePusher interface {
|
||
PushOfflineMessages(ctx context.Context, userID int64) error
|
||
}
|
||
```
|
||
|
||
Handler 新增 `offlinePusher OfflineMessagePusher` 字段,在 `Upgrade` 中 WS 连接成功后调用。
|
||
|
||
**Step 3: 修改 Handler.Upgrade 添加离线推送调用**
|
||
|
||
在 `handler.go` Upgrade 方法的 `h.onlineService.UserOnline()` 之后,添加:
|
||
|
||
```go
|
||
if h.offlinePusher != nil {
|
||
go h.offlinePusher.PushOfflineMessages(c.Request.Context(), claims.UserID)
|
||
}
|
||
```
|
||
|
||
**Step 4: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(im): Task 4 - IM WS 事件处理器 + 离线消息推送"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: IM REST Controller + Router + Wire 集成
|
||
|
||
**Files:**
|
||
- Create: `backend/go-service/app/im/controller/im_controller.go`
|
||
- Create: `backend/go-service/app/im/router.go`
|
||
- Create: `backend/go-service/app/im/provider.go`
|
||
- Modify: `backend/go-service/app/provider/provider.go`(App 新增 IM 字段)
|
||
- Modify: `backend/go-service/app/provider/wire.go`(添加 IMSet)
|
||
- Regenerate: `backend/go-service/app/provider/wire_gen.go`
|
||
- Modify: `backend/go-service/router/router.go`(注册 IM 路由)
|
||
|
||
**Step 1: 创建 IM Controller**
|
||
|
||
Create: `backend/go-service/app/im/controller/im_controller.go`
|
||
|
||
```go
|
||
package controller
|
||
|
||
type IMController struct {
|
||
imService *service.IMService
|
||
}
|
||
|
||
func NewIMController(svc *service.IMService) *IMController {
|
||
return &IMController{imService: svc}
|
||
}
|
||
```
|
||
|
||
实现 7 个 REST handler:
|
||
- `GetConversations` — GET /conversations
|
||
- `GetMessages` — GET /conversations/:id/messages
|
||
- `PinConversation` — PUT /conversations/:id/pin
|
||
- `DeleteConversation` — DELETE /conversations/:id
|
||
- `ClearMessages` — DELETE /conversations/:id/messages
|
||
- `MarkAsRead` — PUT /conversations/:id/read
|
||
- `SearchMessages` — GET /messages/search
|
||
|
||
**Step 2: 创建 IM Router**
|
||
|
||
Create: `backend/go-service/app/im/router.go`
|
||
|
||
```go
|
||
package im
|
||
|
||
func RegisterRoutes(r *gin.Engine, ctrl *controller.IMController, jwtAuth gin.HandlerFunc) {
|
||
authed := r.Group("/api/v1/im")
|
||
authed.Use(jwtAuth)
|
||
{
|
||
authed.GET("/conversations", ctrl.GetConversations)
|
||
authed.GET("/conversations/:id/messages", ctrl.GetMessages)
|
||
authed.PUT("/conversations/:id/pin", ctrl.PinConversation)
|
||
authed.DELETE("/conversations/:id", ctrl.DeleteConversation)
|
||
authed.DELETE("/conversations/:id/messages", ctrl.ClearMessages)
|
||
authed.PUT("/conversations/:id/read", ctrl.MarkAsRead)
|
||
authed.GET("/messages/search", ctrl.SearchMessages)
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: 创建 IM Provider**
|
||
|
||
Create: `backend/go-service/app/im/provider.go`
|
||
|
||
```go
|
||
package im
|
||
|
||
var IMSet = wire.NewSet(
|
||
dao.NewConversationDAO,
|
||
dao.NewMessageDAO,
|
||
service.NewIMService,
|
||
controller.NewIMController,
|
||
handler.NewMessageHandler,
|
||
)
|
||
```
|
||
|
||
**Step 4: 修改 provider.go — App 新增字段**
|
||
|
||
在 `App` struct 和 `NewApp` 中添加:
|
||
- `IMController *imController.IMController`
|
||
- `IMHandler *imHandler.MessageHandler`
|
||
|
||
**Step 5: 修改 wire.go 和重新生成 wire_gen.go**
|
||
|
||
添加 `IMSet` 到 Wire injector,添加接口绑定:
|
||
- `wire.Bind(new(imService.FriendChecker), new(*friendshipDAO))`
|
||
- `wire.Bind(new(imService.UserInfoGetter), new(*friendshipDAO))`
|
||
- `wire.Bind(new(wsApp.OfflineMessagePusher), new(*imService.IMService))`
|
||
|
||
```bash
|
||
cd backend/go-service && wire ./app/provider/
|
||
```
|
||
|
||
**Step 6: 修改 router.go**
|
||
|
||
在 `router/router.go` 的 Setup 函数中添加:
|
||
|
||
```go
|
||
import imApp "github.com/echochat/backend/app/im"
|
||
|
||
imApp.RegisterRoutes(engine, app.IMController, jwtAuth)
|
||
```
|
||
|
||
并在 Router.Setup 中注册 IM 事件处理器:
|
||
|
||
```go
|
||
app.IMHandler.RegisterEvents(app.Hub)
|
||
```
|
||
|
||
**Step 7: 验证编译**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
```
|
||
|
||
**Step 8: 启动后端验证**
|
||
|
||
```bash
|
||
cd backend/go-service && go run cmd/server/main.go
|
||
```
|
||
|
||
Expected: 服务启动成功,im_conversations / im_conversation_members / im_messages 表自动创建
|
||
|
||
**Step 9: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(im): Task 5 - REST Controller + Router + Wire 完整集成"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: 前台 chat Store + API 封装 + WS 事件集成
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/chat.js`
|
||
- Create: `frontend/src/store/chat.js`
|
||
- Modify: `frontend/src/services/websocket.js`(如需添加 send 到 store)
|
||
- Modify: `frontend/src/components/CustomTabBar.vue`(未读 badge)
|
||
|
||
**Step 1: 创建 chat API**
|
||
|
||
Create: `frontend/src/api/chat.js`
|
||
|
||
封装 7 个 REST API:
|
||
- `getConversations()` — GET /api/v1/im/conversations
|
||
- `getMessages(conversationId, beforeId, limit)` — GET /api/v1/im/conversations/:id/messages
|
||
- `pinConversation(conversationId, isPinned)` — PUT /api/v1/im/conversations/:id/pin
|
||
- `deleteConversation(conversationId)` — DELETE /api/v1/im/conversations/:id
|
||
- `clearMessages(conversationId)` — DELETE /api/v1/im/conversations/:id/messages
|
||
- `markAsRead(conversationId)` — PUT /api/v1/im/conversations/:id/read
|
||
- `searchMessages(keyword)` — GET /api/v1/im/messages/search
|
||
|
||
**Step 2: 创建 chat Store**
|
||
|
||
Create: `frontend/src/store/chat.js`
|
||
|
||
Pinia Store,包含:
|
||
- 状态:conversations, currentConversation, messages, totalUnread, typingUsers, pendingMessages
|
||
- Actions:loadConversations, sendMessage, loadHistory, markAsRead, recallMessage, pinConversation, deleteConversation, clearMessages, searchMessages
|
||
- WS 事件注册:initWsListeners()
|
||
- `im.message.new` → 更新会话列表 + 消息列表 + 未读数
|
||
- `im.message.send.ack` → 更新 pending 消息状态为已发送
|
||
- `im.message.recalled` → 标记消息为已撤回
|
||
- `im.typing.notify` → 更新 typingUsers
|
||
- `im.conversation.unread` → 初始化会话未读数
|
||
|
||
**Step 3: 修改 CustomTabBar 添加未读 badge**
|
||
|
||
修改 `frontend/src/components/CustomTabBar.vue`:
|
||
- 引入 `useChatStore`
|
||
- 消息 Tab 显示 `chatStore.totalUnread` badge(红色圆点/数字)
|
||
|
||
**Step 4: 验证编译**
|
||
|
||
```bash
|
||
cd frontend && npm run build:h5 2>&1 | head -20
|
||
```
|
||
|
||
Expected: 编译成功
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(frontend): Task 6 - chat Store + API + WS 事件 + TabBar badge"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: 前台会话列表页 + 聊天页
|
||
|
||
**前置操作:** 使用 ui-ux-pro-max 技能包进行页面设计
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/chat/index.vue`(改造为会话列表)
|
||
- Create: `frontend/src/pages/chat/conversation.vue`(聊天页)
|
||
- Modify: `frontend/src/pages.json`(添加 conversation 页面路由)
|
||
|
||
**Step 1: 读取 ui-ux-pro-max 技能包**
|
||
|
||
```bash
|
||
npx openskills read ui-ux-pro-max
|
||
```
|
||
|
||
使用脚本搜索聊天/消息页面的设计参考。
|
||
|
||
**Step 2: 注册新页面路由**
|
||
|
||
在 `pages.json` 的 pages 数组中添加:
|
||
|
||
```json
|
||
{
|
||
"path": "pages/chat/conversation",
|
||
"style": {
|
||
"navigationBarTitleText": "聊天",
|
||
"navigationStyle": "custom"
|
||
}
|
||
}
|
||
```
|
||
|
||
**Step 3: 实现会话列表页 (chat/index.vue)**
|
||
|
||
主要功能:
|
||
- 搜索栏(点击跳转 search 页)
|
||
- 会话列表:v-for conversations,展示头像/昵称/最后消息/时间/未读badge
|
||
- 排序:置顶在前,再按 lastMsgTime 降序
|
||
- 长按操作菜单(uni.showActionSheet):置顶、删除、清空
|
||
- 点击跳转:`uni.navigateTo({ url: '/pages/chat/conversation?conversationId=X&userId=Y' })`
|
||
- 空状态:"暂无消息"
|
||
- 使用 `CustomTabBar :current="0"`
|
||
|
||
**Step 4: 实现聊天页 (chat/conversation.vue)**
|
||
|
||
主要功能:
|
||
- 自定义顶部导航:对方昵称、"正在输入…"、设置图标
|
||
- 消息列表(scroll-view,scroll-into-view 自动滚底)
|
||
- 上拉加载历史(loadHistory with beforeId)
|
||
- 消息气泡:
|
||
- 自己的消息右对齐(蓝色背景)
|
||
- 对方消息左对齐(灰色背景)
|
||
- 已撤回消息灰色提示
|
||
- 消息状态:发送中(spinner)、已发送(✓)、失败(重试图标)
|
||
- 长按消息 → 撤回(仅自己的、2分钟内)
|
||
- 底部输入区:textarea + 发送按钮
|
||
- 输入时触发 im.typing.start(节流 3s),停止输入 3s 后发 im.typing.stop
|
||
- onLoad:通过 userId 参数加载/进入会话
|
||
- onUnload:清理 typing 状态
|
||
|
||
**Step 5: 验证编译**
|
||
|
||
```bash
|
||
cd frontend && npm run build:h5 2>&1 | head -20
|
||
```
|
||
|
||
**Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(frontend): Task 7 - 会话列表页 + 聊天页(ui-ux-pro-max)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: 前台会话设置页 + 消息搜索页 + 联系人模块改造
|
||
|
||
**前置操作:** 使用 ui-ux-pro-max 技能包
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/pages/chat/settings.vue`
|
||
- Create: `frontend/src/pages/chat/search.vue`
|
||
- Modify: `frontend/src/pages.json`(添加 settings/search 路由)
|
||
- Modify: `frontend/src/pages/contact/detail.vue`(sendMessage 改为跳转)
|
||
- Modify: `frontend/src/pages/contact/index.vue`(列表项点击进入聊天)
|
||
|
||
**Step 1: 注册新页面路由**
|
||
|
||
在 `pages.json` 添加:
|
||
|
||
```json
|
||
{ "path": "pages/chat/settings", "style": { "navigationBarTitleText": "聊天设置" } },
|
||
{ "path": "pages/chat/search", "style": { "navigationBarTitleText": "搜索消息", "navigationStyle": "custom" } }
|
||
```
|
||
|
||
**Step 2: 实现会话设置页 (chat/settings.vue)**
|
||
|
||
- 对方信息卡片(头像 + 昵称 + 用户名)
|
||
- 置顶聊天 Switch
|
||
- 清空聊天记录按钮(uni.showModal 二次确认)
|
||
- 删除会话按钮(uni.showModal 二次确认 → navigateBack)
|
||
|
||
**Step 3: 实现消息搜索页 (chat/search.vue)**
|
||
|
||
- 搜索输入框(防抖 300ms)
|
||
- 搜索结果按会话分组(v-for groups → 会话名 + 消息列表)
|
||
- 点击消息跳转到聊天页
|
||
|
||
**Step 4: 改造好友详情页 (contact/detail.vue)**
|
||
|
||
```javascript
|
||
const sendMessage = () => {
|
||
uni.navigateTo({ url: `/pages/chat/conversation?userId=${userId.value}` })
|
||
}
|
||
```
|
||
|
||
**Step 5: 改造好友列表页 (contact/index.vue)**
|
||
|
||
好友列表项添加点击跳转聊天的快捷入口(或双击/右侧图标)。
|
||
|
||
**Step 6: 验证编译**
|
||
|
||
```bash
|
||
cd frontend && npm run build:h5 2>&1 | head -20
|
||
```
|
||
|
||
**Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(frontend): Task 8 - 设置页 + 搜索页 + 联系人联动"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: 集成测试 + 文档更新 + 代码审查
|
||
|
||
**Files:**
|
||
- Modify: `docs/progress/CURRENT_STATUS.md`
|
||
- Modify: `docs/plans/2026-03-03-phase2b-design.md`(状态更新)
|
||
- Modify: `docs/plans/2026-02-27-echochat-system-design.md`
|
||
- Modify: `docs/architecture/system-architecture.md`(如存在)
|
||
- Modify: `.cursor/rules/project-context.mdc`
|
||
- Create: `docs/api/frontend/im.md`(IM API 文档)
|
||
- Create: `docs/api/websocket-im.md`(WS IM 事件文档)
|
||
|
||
**Step 1: 三端编译验证**
|
||
|
||
```bash
|
||
cd backend/go-service && go build ./...
|
||
cd frontend && npm run build:h5
|
||
cd admin && npm run build
|
||
```
|
||
|
||
Expected: 全部编译成功
|
||
|
||
**Step 2: 启动后端 + 前端功能测试**
|
||
|
||
```bash
|
||
cd backend/go-service && go run cmd/server/main.go &
|
||
cd frontend && npm run dev:h5
|
||
```
|
||
|
||
使用 Playwright MCP 验证核心流程:
|
||
1. 用 testuser1 登录 → 联系人 → 点击好友 → 发消息跳转聊天页
|
||
2. 发送一条消息 → 收到 ACK
|
||
3. 用另一个账号登录 → 会话列表显示新消息 → 进入聊天 → 看到消息
|
||
4. 撤回消息 → 对方看到撤回提示
|
||
5. 消息搜索 → 输入关键词 → 显示结果
|
||
|
||
**Step 3: 更新项目文档**
|
||
|
||
按项目规则(文档自动同步规则)更新:
|
||
- `CURRENT_STATUS.md`:Phase 2b Task 状态表
|
||
- `project-context.mdc`:当前进度更新
|
||
- 总体设计文档:API 列表更新
|
||
- 新增 IM API 文档
|
||
- 新增 WS IM 事件文档
|
||
|
||
**Step 4: 代码审查**
|
||
|
||
使用 `code-reviewer` 子代理进行结构化审查。
|
||
|
||
**Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "docs: Task 9 - Phase 2b 集成测试通过 + 文档同步"
|
||
```
|
||
|
||
---
|
||
|
||
## 执行说明
|
||
|
||
本计划共 **10 个 Task**,建议执行方式:
|
||
|
||
1. **Task 0-5(后端)**:按顺序执行,每个 Task 完成后编译验证 + 提交
|
||
2. **Task 6-8(前端)**:依赖后端 API 就绪,前端页面使用 ui-ux-pro-max 设计
|
||
3. **Task 9(集成)**:全端验证 + 文档更新
|
||
|
||
预计工作量:后端 6 个 Task + 前端 3 个 Task + 集成 1 个 Task
|