From 9399f86a19fb842bfaf30c06b7ac13b30d6fb6ea Mon Sep 17 00:00:00 2001 From: bujinyuan Date: Wed, 4 Mar 2026 10:03:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=8D=95=E8=81=8A=E9=A1=B5=E9=9D=A2bug?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D+=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=AB=AF=E5=A5=BD=E5=8F=8B=E9=A1=B5=E9=9D=A2bug=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D+=E9=A1=B9=E7=9B=AE=E8=BF=9B=E5=BA=A6=E3=80=81?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E6=96=87=E4=BB=B6=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/project-context.mdc | 7 +- admin/src/views/contact/list.vue | 4 +- .../app/contact/dao/friendship_dao.go | 33 ++- .../app/contact/service/contact_service.go | 28 +- .../go-service/app/im/dao/conversation_dao.go | 49 +++- backend/go-service/app/im/dao/message_dao.go | 28 +- .../app/im/handler/event_handler.go | 20 +- .../app/im/model/conversation_member.go | 5 +- .../go-service/app/im/service/im_service.go | 91 ++++-- backend/go-service/app/provider/wire.go | 5 + backend/go-service/app/provider/wire_gen.go | 2 +- backend/go-service/app/ws/handler.go | 16 +- backend/go-service/app/ws/online_service.go | 33 ++- deploy/docker/postgres/init.sql | 28 +- docs/api/README.md | 8 +- docs/api/frontend/im.md | 264 +++++++++--------- docs/api/websocket.md | 82 ++++-- docs/architecture/system-architecture.md | 6 +- .../2026-02-27-echochat-system-design.md | 171 +++++++----- docs/plans/2026-03-03-phase2b-design.md | 78 +++--- docs/progress/CURRENT_STATUS.md | 44 ++- frontend/src/App.vue | 36 ++- frontend/src/pages/auth/login.vue | 7 + frontend/src/pages/chat/conversation.vue | 10 +- frontend/src/pages/chat/search.vue | 217 +++++++------- frontend/src/pages/chat/settings.vue | 26 +- frontend/src/pages/contact/request.vue | 2 + frontend/src/pages/contact/search.vue | 24 +- frontend/src/store/chat.js | 40 ++- frontend/src/store/user.js | 4 +- 30 files changed, 838 insertions(+), 530 deletions(-) diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 1606591..a8fcf43 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -16,9 +16,12 @@ alwaysApply: true - **Phase 1(基础设施与用户认证)**:✅ 全部完成(11 个 Task) - **Phase 2a(WebSocket 实时通讯与联系人管理)**:✅ 全部完成(13 个 Task + 后期 Bug 修复 3 项) -- **Phase 2b(即时通讯消息系统)**:✅ 核心功能开发完成(10 个 Task),设计文档 `docs/plans/2026-03-03-phase2b-design.md` +- **Phase 2b(即时通讯消息系统)**:✅ 全部完成(10 个 Task + 代码审查修复 7 项 + 用户测试修复 8 项),设计文档 `docs/plans/2026-03-03-phase2b-design.md` - 分支:`feature/phase2b-instant-messaging` -- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler) +- 代码审查修复:ClearHistory 个人视图、Redis 负数保护、N+1 查询优化、全文索引搜索、撤回更新预览、推送补全 sender 信息 +- 用户测试修复:好友申请/接受唯一约束、Redis 在线状态残留清理、WS 全局初始化、管理端字段修正、好友列表在线状态初始值、聊天页布局约束 +- **Phase 2c(群聊与增强)**:待规划设计 +- **跨模块通信模式**:已建立接口注入标准(ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAO,im.OfflineMessagePusher → ws.Handler,contact.OnlineChecker → ws.OnlineService) ## 项目概述 diff --git a/admin/src/views/contact/list.vue b/admin/src/views/contact/list.vue index 8b92643..28f345a 100644 --- a/admin/src/views/contact/list.vue +++ b/admin/src/views/contact/list.vue @@ -32,7 +32,7 @@ @@ -130,7 +130,7 @@ const fetchList = async () => { const handleDelete = async (row) => { try { await ElMessageBox.confirm( - `确定要删除用户 ${row.username || row.user_id} 与 ${row.friend_username || row.friend_id} 之间的好友关系吗?此操作将双向解除。`, + `确定要删除用户 ${row.user_username || row.user_id} 与 ${row.friend_username || row.friend_id} 之间的好友关系吗?此操作将双向解除。`, '删除确认', { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' } ) diff --git a/backend/go-service/app/contact/dao/friendship_dao.go b/backend/go-service/app/contact/dao/friendship_dao.go index c33d156..938686a 100644 --- a/backend/go-service/app/contact/dao/friendship_dao.go +++ b/backend/go-service/app/contact/dao/friendship_dao.go @@ -42,7 +42,7 @@ func (d *FriendshipDAO) CreateRequest(ctx context.Context, userID, friendID int6 return f, err } -// AcceptRequest 接受好友申请(事务内:更新 A→B 为 accepted + 创建 B→A) +// AcceptRequest 接受好友申请(事务内:更新 A→B 为 accepted + 创建或更新 B→A 为 accepted) func (d *FriendshipDAO) AcceptRequest(ctx context.Context, requestID, userID int64) error { funcName := "dao.friendship_dao.AcceptRequest" logs.Info(ctx, funcName, "接受好友申请", @@ -65,6 +65,15 @@ func (d *FriendshipDAO) AcceptRequest(ctx context.Context, requestID, userID int return err } + var existing model.Friendship + err := tx.Where("user_id = ? AND friend_id = ?", userID, req.UserID).First(&existing).Error + if err == nil { + return tx.Model(&existing).Updates(map[string]interface{}{ + "status": constants.FriendshipStatusAccepted, + "updated_at": now, + }).Error + } + reverse := &model.Friendship{ UserID: userID, FriendID: req.UserID, @@ -268,6 +277,28 @@ func (d *FriendshipDAO) HasPendingRequest(ctx context.Context, userID, friendID return count > 0, err } +// ReactivateRejectedRequest 将已拒绝的申请重新激活为待处理状态 +// 返回是否找到并更新了已拒绝的记录 +func (d *FriendshipDAO) ReactivateRejectedRequest(ctx context.Context, userID, friendID int64, message string) (bool, error) { + funcName := "dao.friendship_dao.ReactivateRejectedRequest" + logs.Info(ctx, funcName, "重新激活已拒绝的好友申请", + zap.Int64("user_id", userID), zap.Int64("friend_id", friendID)) + + result := d.db.WithContext(ctx). + Model(&model.Friendship{}). + Where("user_id = ? AND friend_id = ? AND status = ?", userID, friendID, constants.FriendshipStatusRejected). + Updates(map[string]interface{}{ + "status": constants.FriendshipStatusPending, + "message": message, + }) + + if result.Error != nil { + logs.Error(ctx, funcName, "重新激活好友申请失败", zap.Error(result.Error)) + return false, result.Error + } + return result.RowsAffected > 0, nil +} + // GetRequestByID 根据 ID 获取好友申请 func (d *FriendshipDAO) GetRequestByID(ctx context.Context, id int64) (*model.Friendship, error) { var f model.Friendship diff --git a/backend/go-service/app/contact/service/contact_service.go b/backend/go-service/app/contact/service/contact_service.go index 4e863ca..b5f3982 100644 --- a/backend/go-service/app/contact/service/contact_service.go +++ b/backend/go-service/app/contact/service/contact_service.go @@ -25,11 +25,17 @@ var ( ErrUserNotFound = errors.New("用户不存在") ) +// OnlineChecker 在线状态查询接口(避免直接依赖 ws 模块的 OnlineService) +type OnlineChecker interface { + BatchCheckOnline(ctx context.Context, userIDs []int64) map[int64]bool +} + // ContactService 联系人业务服务 type ContactService struct { friendshipDAO *dao.FriendshipDAO friendGroupDAO *dao.FriendGroupDAO pubsub *ws.PubSub + onlineChecker OnlineChecker } // NewContactService 创建 ContactService 实例 @@ -37,11 +43,13 @@ func NewContactService( friendshipDAO *dao.FriendshipDAO, friendGroupDAO *dao.FriendGroupDAO, pubsub *ws.PubSub, + onlineChecker OnlineChecker, ) *ContactService { return &ContactService{ friendshipDAO: friendshipDAO, friendGroupDAO: friendGroupDAO, pubsub: pubsub, + onlineChecker: onlineChecker, } } @@ -80,10 +88,16 @@ func (s *ContactService) SendFriendRequest(ctx context.Context, userID, targetID return ErrPendingExists } - _, err = s.friendshipDAO.CreateRequest(ctx, userID, targetID, message) + reactivated, err := s.friendshipDAO.ReactivateRejectedRequest(ctx, userID, targetID, message) if err != nil { return err } + if !reactivated { + _, err = s.friendshipDAO.CreateRequest(ctx, userID, targetID, message) + if err != nil { + return err + } + } push := ws.NewPushMessage("notify.friend.request", map[string]interface{}{ "from_user_id": userID, @@ -141,7 +155,7 @@ func (s *ContactService) RejectFriendRequest(ctx context.Context, requestID, use return err } -// GetFriendList 获取好友列表 +// GetFriendList 获取好友列表(包含在线状态) func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupID *int64) ([]dto.FriendInfo, error) { funcName := "service.contact_service.GetFriendList" logs.Debug(ctx, funcName, "获取好友列表", zap.Int64("user_id", userID)) @@ -151,6 +165,15 @@ func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupI return nil, err } + friendIDs := make([]int64, 0, len(friends)) + for _, f := range friends { + friendIDs = append(friendIDs, f.UserID) + } + onlineMap := make(map[int64]bool) + if s.onlineChecker != nil && len(friendIDs) > 0 { + onlineMap = s.onlineChecker.BatchCheckOnline(ctx, friendIDs) + } + result := make([]dto.FriendInfo, 0, len(friends)) for _, f := range friends { result = append(result, dto.FriendInfo{ @@ -161,6 +184,7 @@ func (s *ContactService) GetFriendList(ctx context.Context, userID int64, groupI Avatar: f.Avatar, Remark: f.Remark, GroupID: f.GroupID, + IsOnline: onlineMap[f.UserID], CreatedAt: f.CreatedAt.Format("2006-01-02 15:04:05"), }) } diff --git a/backend/go-service/app/im/dao/conversation_dao.go b/backend/go-service/app/im/dao/conversation_dao.go index 7b8cd56..fd10968 100644 --- a/backend/go-service/app/im/dao/conversation_dao.go +++ b/backend/go-service/app/im/dao/conversation_dao.go @@ -77,20 +77,22 @@ func (d *ConversationDAO) CreateWithMembers(ctx context.Context, conv *model.Con } // GetUserConversations 获取用户的会话列表(排除已软删除的) -// 返回会话基本信息和该用户的成员视图(未读数、置顶等) +// 返回会话基本信息、该用户的成员视图(未读数、置顶等)和单聊对方用户 ID func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64) ([]ConversationWithMember, error) { funcName := "dao.conversation_dao.GetUserConversations" logs.Debug(ctx, funcName, "查询用户会话列表", zap.Int64("user_id", userID)) var results []ConversationWithMember err := d.db.WithContext(ctx). - Raw(`SELECT c.id, c.type, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id, - cm.is_pinned, cm.unread_count + Raw(`SELECT c.id, c.type, c.last_message_id, c.last_msg_content, c.last_msg_time, c.last_msg_sender_id, + cm.is_pinned, cm.unread_count, cm.clear_before_msg_id, + peer.user_id AS peer_user_id FROM im_conversations c - JOIN im_conversation_members cm ON cm.conversation_id = c.id - WHERE cm.user_id = ? AND cm.is_deleted = false + JOIN im_conversation_members cm ON cm.conversation_id = c.id AND cm.user_id = ? + LEFT JOIN im_conversation_members peer ON peer.conversation_id = c.id AND peer.user_id != ? + WHERE cm.is_deleted = false ORDER BY cm.is_pinned DESC, c.last_msg_time DESC NULLS LAST`, - userID). + userID, userID). Scan(&results).Error if err != nil { @@ -99,15 +101,18 @@ func (d *ConversationDAO) GetUserConversations(ctx context.Context, userID int64 return results, err } -// ConversationWithMember 会话列表查询结果(JOIN 成员表) +// ConversationWithMember 会话列表查询结果(JOIN 成员表 + LEFT JOIN 对方成员) type ConversationWithMember struct { - ID int64 `json:"id"` - Type int `json:"type"` - LastMsgContent string `json:"last_msg_content"` - LastMsgTime *time.Time `json:"last_msg_time"` - LastMsgSenderID *int64 `json:"last_msg_sender_id"` - IsPinned bool `json:"is_pinned"` - UnreadCount int `json:"unread_count"` + ID int64 `json:"id"` + Type int `json:"type"` + LastMessageID *int64 `json:"last_message_id"` + LastMsgContent string `json:"last_msg_content"` + LastMsgTime *time.Time `json:"last_msg_time"` + LastMsgSenderID *int64 `json:"last_msg_sender_id"` + IsPinned bool `json:"is_pinned"` + UnreadCount int `json:"unread_count"` + ClearBeforeMsgID int64 `json:"clear_before_msg_id"` + PeerUserID int64 `json:"peer_user_id"` } // GetMember 获取指定会话中指定用户的成员记录 @@ -244,6 +249,22 @@ func (d *ConversationDAO) GetUnreadConversations(ctx context.Context, userID int return results, err } +// UpdateClearBefore 更新用户的清空记录截止消息 ID(个人视图操作,不影响对方) +func (d *ConversationDAO) UpdateClearBefore(ctx context.Context, conversationID, userID, lastMsgID int64) error { + funcName := "dao.conversation_dao.UpdateClearBefore" + logs.Info(ctx, funcName, "更新清空记录截止 ID", + zap.Int64("conversation_id", conversationID), zap.Int64("user_id", userID), + zap.Int64("last_msg_id", lastMsgID)) + + return d.db.WithContext(ctx). + Model(&model.ConversationMember{}). + Where("conversation_id = ? AND user_id = ?", conversationID, userID). + Updates(map[string]interface{}{ + "clear_before_msg_id": lastMsgID, + "unread_count": 0, + }).Error +} + // GetByID 根据 ID 获取会话 func (d *ConversationDAO) GetByID(ctx context.Context, id int64) (*model.Conversation, error) { var conv model.Conversation diff --git a/backend/go-service/app/im/dao/message_dao.go b/backend/go-service/app/im/dao/message_dao.go index 8ef04de..e97dd50 100644 --- a/backend/go-service/app/im/dao/message_dao.go +++ b/backend/go-service/app/im/dao/message_dao.go @@ -45,12 +45,14 @@ func (d *MessageDAO) GetByID(ctx context.Context, id int64) (*model.Message, err // GetByConversation 获取会话的历史消息(游标分页,按 ID 降序) // beforeID > 0 时作为游标,查询 ID 小于 beforeID 的消息 -// beforeID = 0 时查询最新的 limit 条消息 -func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64, beforeID int64, limit int) ([]model.Message, error) { +// clearBeforeMsgID > 0 时过滤掉用户已清空的消息(个人视图) +func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64, beforeID int64, clearBeforeMsgID int64, limit int) ([]model.Message, error) { funcName := "dao.message_dao.GetByConversation" logs.Debug(ctx, funcName, "查询历史消息", zap.Int64("conversation_id", conversationID), - zap.Int64("before_id", beforeID), zap.Int("limit", limit)) + zap.Int64("before_id", beforeID), + zap.Int64("clear_before_msg_id", clearBeforeMsgID), + zap.Int("limit", limit)) query := d.db.WithContext(ctx). Where("conversation_id = ? AND status != ?", conversationID, constants.MessageStatusDeleted) @@ -58,6 +60,9 @@ func (d *MessageDAO) GetByConversation(ctx context.Context, conversationID int64 if beforeID > 0 { query = query.Where("id < ?", beforeID) } + if clearBeforeMsgID > 0 { + query = query.Where("id > ?", clearBeforeMsgID) + } var messages []model.Message err := query.Order("id DESC").Limit(limit).Find(&messages).Error @@ -79,18 +84,6 @@ func (d *MessageDAO) UpdateStatus(ctx context.Context, id int64, status int) err Update("status", status).Error } -// DeleteByConversation 软删除会话中所有消息(标记 status=3) -func (d *MessageDAO) DeleteByConversation(ctx context.Context, conversationID int64) error { - funcName := "dao.message_dao.DeleteByConversation" - logs.Info(ctx, funcName, "清空会话消息", - zap.Int64("conversation_id", conversationID)) - - return d.db.WithContext(ctx). - Model(&model.Message{}). - Where("conversation_id = ? AND status = ?", conversationID, constants.MessageStatusNormal). - Update("status", constants.MessageStatusDeleted).Error -} - // SearchMessages 全局消息搜索(按关键词匹配,仅搜索用户所在会话的消息) // 返回匹配的消息列表(已 JOIN 成员表确保权限) func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword string, limit int) ([]MessageSearchResult, error) { @@ -104,10 +97,11 @@ func (d *MessageDAO) SearchMessages(ctx context.Context, userID int64, keyword s FROM im_messages m JOIN im_conversation_members cm ON cm.conversation_id = m.conversation_id WHERE cm.user_id = ? AND cm.is_deleted = false - AND m.status = ? AND m.content LIKE ? + AND m.status = ? + AND to_tsvector('simple', m.content) @@ plainto_tsquery('simple', ?) ORDER BY m.created_at DESC LIMIT ?`, - userID, constants.MessageStatusNormal, "%"+keyword+"%", limit). + userID, constants.MessageStatusNormal, keyword, limit). Scan(&results).Error if err != nil { diff --git a/backend/go-service/app/im/handler/event_handler.go b/backend/go-service/app/im/handler/event_handler.go index 69f94e5..7688092 100644 --- a/backend/go-service/app/im/handler/event_handler.go +++ b/backend/go-service/app/im/handler/event_handler.go @@ -111,7 +111,7 @@ func (h *EventHandler) handleMarkRead(client *ws.Client, msg *ws.Message) { h.sendACK(client, msg, 0, "ok", nil) } -// handleTyping 处理正在输入事件(转发给对方) +// handleTyping 处理正在输入事件(通过 PubSub 转发给对方,支持跨实例) func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) { funcName := "handler.event_handler.handleTyping" ctx := context.Background() @@ -123,23 +123,7 @@ func (h *EventHandler) handleTyping(client *ws.Client, msg *ws.Message) { return } - peerID, err := h.imService.GetPeerUserID(ctx, req.ConversationID, client.UserID) - if err != nil { - logs.Warn(ctx, funcName, "查询对方用户 ID 失败", - zap.Int64("conversation_id", req.ConversationID), zap.Error(err)) - return - } - - push := ws.NewPushMessage("im.typing", map[string]interface{}{ - "conversation_id": req.ConversationID, - "user_id": client.UserID, - }) - data, err := ws.MarshalPush(push) - if err != nil { - return - } - - h.hub.SendToUser(peerID, data) + h.imService.PushTypingNotification(ctx, req.ConversationID, client.UserID) } // sendACK 发送 ACK 响应给客户端 diff --git a/backend/go-service/app/im/model/conversation_member.go b/backend/go-service/app/im/model/conversation_member.go index d8d0ec6..6ab62b7 100644 --- a/backend/go-service/app/im/model/conversation_member.go +++ b/backend/go-service/app/im/model/conversation_member.go @@ -11,8 +11,9 @@ type ConversationMember struct { 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"` // 该成员最后已读消息 ID - CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间 + LastReadMsgID int64 `json:"last_read_msg_id" gorm:"default:0"` // 该成员最后已读消息 ID + ClearBeforeMsgID int64 `json:"clear_before_msg_id" gorm:"default:0"` // 清空记录时的消息截止 ID(个人视图,不影响对方) + CreatedAt time.Time `json:"created_at" gorm:"not null;autoCreateTime;type:timestamp(0)"` // 加入会话时间 UpdatedAt time.Time `json:"updated_at" gorm:"not null;autoUpdateTime;type:timestamp(0)"` // 最后更新时间 } diff --git a/backend/go-service/app/im/service/im_service.go b/backend/go-service/app/im/service/im_service.go index 02d422e..8ba1401 100644 --- a/backend/go-service/app/im/service/im_service.go +++ b/backend/go-service/app/im/service/im_service.go @@ -160,7 +160,7 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se s.incrementTotalUnread(ctx, peerID) - s.pushToUser(ctx, peerID, "im.message.new", map[string]interface{}{ + pushData := map[string]interface{}{ "id": msg.ID, "conversation_id": convID, "sender_id": senderID, @@ -168,12 +168,18 @@ func (s *IMService) SendMessage(ctx context.Context, senderID int64, req *dto.Se "content": msg.Content, "client_msg_id": msg.ClientMsgID, "created_at": msg.CreatedAt.Format("2006-01-02 15:04:05"), - }) + } + if senderUsers, sErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}); sErr == nil && len(senderUsers) > 0 { + pushData["sender_name"] = senderUsers[0].Nickname + pushData["sender_avatar"] = senderUsers[0].Avatar + } + s.pushToUser(ctx, peerID, "im.message.new", pushData) return s.toMessageDTO(msg), nil } // RecallMessage 撤回消息(2分钟内) +// 撤回成功后,若被撤回消息是会话最后一条,则同步更新会话预览文本 func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID int64) error { funcName := "service.im_service.RecallMessage" logs.Info(ctx, funcName, "撤回消息", @@ -195,6 +201,20 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID return err } + conv, err := s.convDAO.GetByID(ctx, msg.ConversationID) + if err != nil { + logs.Error(ctx, funcName, "获取会话信息失败", zap.Error(err)) + } else if conv.LastMessageID != nil && *conv.LastMessageID == msg.ID { + senderInfo, infoErr := s.userInfoGetter.GetUsersByIDs(ctx, []int64{senderID}) + recallText := "撤回了一条消息" + if infoErr == nil && len(senderInfo) > 0 { + recallText = senderInfo[0].Nickname + " 撤回了一条消息" + } + if updateErr := s.convDAO.UpdateLastMessage(ctx, msg.ConversationID, msg.ID, recallText, senderID, msg.CreatedAt); updateErr != nil { + logs.Error(ctx, funcName, "更新会话预览失败", zap.Error(updateErr)) + } + } + memberIDs, err := s.convDAO.GetConversationMemberIDs(ctx, msg.ConversationID) if err != nil { logs.Error(ctx, funcName, "获取会话成员失败", zap.Error(err)) @@ -207,6 +227,7 @@ func (s *IMService) RecallMessage(ctx context.Context, senderID int64, messageID s.pushToUser(ctx, uid, "im.message.recalled", map[string]interface{}{ "message_id": messageID, "conversation_id": msg.ConversationID, + "sender_id": senderID, }) } @@ -224,15 +245,10 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto } peerIDs := make([]int64, 0, len(convs)) - convIDToPeerID := make(map[int64]int64, len(convs)) for _, c := range convs { - peerID, err := s.convDAO.GetPeerUserID(ctx, c.ID, userID) - if err != nil { - logs.Error(ctx, funcName, "查询对方用户 ID 失败", zap.Int64("conv_id", c.ID), zap.Error(err)) - continue + if c.PeerUserID > 0 { + peerIDs = append(peerIDs, c.PeerUserID) } - peerIDs = append(peerIDs, peerID) - convIDToPeerID[c.ID] = peerID } userMap := make(map[int64]*userBrief) @@ -250,7 +266,7 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto list := make([]dto.ConversationDTO, 0, len(convs)) for _, c := range convs { - peerID := convIDToPeerID[c.ID] + peerID := c.PeerUserID item := dto.ConversationDTO{ ID: c.ID, Type: c.Type, @@ -260,6 +276,10 @@ func (s *IMService) GetConversationList(ctx context.Context, userID int64) (*dto IsPinned: c.IsPinned, UnreadCount: c.UnreadCount, } + if c.ClearBeforeMsgID > 0 && c.LastMessageID != nil && *c.LastMessageID <= c.ClearBeforeMsgID { + item.LastMsgContent = "" + item.LastMsgSenderID = nil + } if c.LastMsgTime != nil { item.LastMsgTime = c.LastMsgTime.Format("2006-01-02 15:04:05") } @@ -292,7 +312,7 @@ func (s *IMService) GetHistoryMessages(ctx context.Context, userID int64, req *d limit = maxPageSize } - messages, err := s.msgDAO.GetByConversation(ctx, req.ConversationID, req.BeforeID, limit+1) + messages, err := s.msgDAO.GetByConversation(ctx, req.ConversationID, req.BeforeID, member.ClearBeforeMsgID, limit+1) if err != nil { return nil, err } @@ -370,7 +390,8 @@ func (s *IMService) DeleteConversation(ctx context.Context, userID int64, conver return s.convDAO.SoftDeleteMember(ctx, conversationID, userID) } -// ClearHistory 清空聊天记录(标记消息为已删除) +// ClearHistory 清空聊天记录(个人视图操作,仅影响当前用户,不影响对方) +// 通过记录清空截止消息 ID 实现,而非真正删除消息 func (s *IMService) ClearHistory(ctx context.Context, userID int64, conversationID int64) error { funcName := "service.im_service.ClearHistory" logs.Info(ctx, funcName, "清空聊天记录", @@ -380,7 +401,23 @@ func (s *IMService) ClearHistory(ctx context.Context, userID int64, conversation if err != nil || member == nil { return ErrNotMember } - return s.msgDAO.DeleteByConversation(ctx, conversationID) + + latestMsgID, err := s.msgDAO.GetLatestMessageID(ctx, conversationID) + if err != nil { + logs.Error(ctx, funcName, "获取最新消息 ID 失败", zap.Error(err)) + return err + } + + if err := s.convDAO.UpdateClearBefore(ctx, conversationID, userID, latestMsgID); err != nil { + logs.Error(ctx, funcName, "更新清空截止 ID 失败", zap.Error(err)) + return err + } + + if member.UnreadCount > 0 { + s.decrementTotalUnread(ctx, userID, member.UnreadCount) + } + + return nil } // SearchMessages 全局消息搜索 @@ -459,6 +496,20 @@ func (s *IMService) GetPeerUserID(ctx context.Context, conversationID, userID in return s.convDAO.GetPeerUserID(ctx, conversationID, userID) } +// PushTypingNotification 向对方推送正在输入通知(通过 PubSub 支持跨实例) +func (s *IMService) PushTypingNotification(ctx context.Context, conversationID, senderID int64) { + peerID, err := s.convDAO.GetPeerUserID(ctx, conversationID, senderID) + if err != nil { + logs.Warn(ctx, "service.im_service.PushTypingNotification", "查询对方用户 ID 失败", + zap.Int64("conversation_id", conversationID), zap.Error(err)) + return + } + s.pushToUser(ctx, peerID, "im.typing", map[string]interface{}{ + "conversation_id": conversationID, + "user_id": senderID, + }) +} + // ====== 内部辅助方法 ====== // getOrCreatePrivateConversation 查找或创建单聊会话 @@ -510,11 +561,19 @@ func (s *IMService) incrementTotalUnread(ctx context.Context, userID int64) { } } -// decrementTotalUnread Redis 全局未读 -N(下限为 0) +// decrementTotalUnread Redis 全局未读 -N(使用 Lua 脚本保证原子性,下限为 0) func (s *IMService) decrementTotalUnread(ctx context.Context, userID int64, count int) { key := fmt.Sprintf("%s%d", unreadKeyPrefix, userID) - if err := s.rdb.DecrBy(ctx, key, int64(count)).Err(); err != nil { - logs.Error(ctx, "service.im_service.decrementTotalUnread", "Redis DECRBY 失败", + script := redis.NewScript(` + local current = tonumber(redis.call('GET', KEYS[1]) or '0') + local decr = tonumber(ARGV[1]) + local newVal = current - decr + if newVal < 0 then newVal = 0 end + redis.call('SET', KEYS[1], newVal) + return newVal + `) + if err := script.Run(ctx, s.rdb, []string{key}, count).Err(); err != nil { + logs.Error(ctx, "service.im_service.decrementTotalUnread", "Redis Lua 脚本执行失败", zap.Int64("user_id", userID), zap.Error(err)) } } diff --git a/backend/go-service/app/provider/wire.go b/backend/go-service/app/provider/wire.go index e65a057..2e9d3a6 100644 --- a/backend/go-service/app/provider/wire.go +++ b/backend/go-service/app/provider/wire.go @@ -7,7 +7,9 @@ import ( "github.com/echochat/backend/app/admin" "github.com/echochat/backend/app/auth" "github.com/echochat/backend/app/contact" + authService "github.com/echochat/backend/app/auth/service" contactDAO "github.com/echochat/backend/app/contact/dao" + contactService "github.com/echochat/backend/app/contact/service" imApp "github.com/echochat/backend/app/im" imService "github.com/echochat/backend/app/im/service" wsApp "github.com/echochat/backend/app/ws" @@ -24,8 +26,11 @@ func InitializeApp(cfg *config.Config) (*App, error) { wsApp.WSSet, contact.ContactSet, imApp.IMSet, + wire.Bind(new(wsApp.FriendIDsGetter), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(wsApp.TokenValidator), new(*authService.AuthService)), wire.Bind(new(imService.FriendChecker), new(*contactDAO.FriendshipDAO)), wire.Bind(new(imService.UserInfoGetter), new(*contactDAO.FriendshipDAO)), + wire.Bind(new(contactService.OnlineChecker), new(*wsApp.OnlineService)), ) return nil, nil } diff --git a/backend/go-service/app/provider/wire_gen.go b/backend/go-service/app/provider/wire_gen.go index ffe7ba2..8b960c3 100644 --- a/backend/go-service/app/provider/wire_gen.go +++ b/backend/go-service/app/provider/wire_gen.go @@ -56,7 +56,7 @@ func InitializeApp(cfg *config.Config) (*App, error) { contactManageController := controller2.NewContactManageController(contactManageService) handler := ws.ProvideWSHandler(hub, pubSub, jwtConfig, onlineService, authService) friendGroupDAO := dao3.NewFriendGroupDAO(gormDB) - contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub) + contactService := service3.NewContactService(friendshipDAO, friendGroupDAO, pubSub, onlineService) contactController := controller3.NewContactController(contactService) // IM 模块初始化 diff --git a/backend/go-service/app/ws/handler.go b/backend/go-service/app/ws/handler.go index 71cb005..500e421 100644 --- a/backend/go-service/app/ws/handler.go +++ b/backend/go-service/app/ws/handler.go @@ -37,12 +37,12 @@ var upgrader = websocket.Upgrader{ // Handler WebSocket 连接处理器 type Handler struct { - hub *ws.Hub - pubsub *ws.PubSub - jwtCfg *config.JWTConfig - onlineService *OnlineService - tokenValidator TokenValidator - offlinePusher OfflineMessagePusher + hub *ws.Hub + pubsub *ws.PubSub + jwtCfg *config.JWTConfig + onlineService *OnlineService + tokenValidator TokenValidator + offlinePusher OfflineMessagePusher } // NewHandler 创建 WebSocket Handler 实例 @@ -99,8 +99,8 @@ func (h *Handler) Upgrade(c *gin.Context) { client := ws.NewClient(h.hub, conn, claims.UserID) client.SetOnDisconnect(func(userID int64) { - if client.IsClosedByHub() { - logs.Info(nil, "ws.handler.onDisconnect", "连接被 Hub 踢出(重复连接),跳过下线清理", + if client.IsClosedByHub() && h.hub.IsOnline(userID) { + logs.Info(nil, "ws.handler.onDisconnect", "连接被新连接替换,跳过下线清理", zap.Int64("user_id", userID)) return } diff --git a/backend/go-service/app/ws/online_service.go b/backend/go-service/app/ws/online_service.go index 1a2ffd4..3b7386d 100644 --- a/backend/go-service/app/ws/online_service.go +++ b/backend/go-service/app/ws/online_service.go @@ -39,13 +39,44 @@ type OnlineService struct { } // NewOnlineService 创建 OnlineService 实例 +// 启动时清理 Redis 中残留的在线状态数据,防止服务重启后出现"幽灵在线" func NewOnlineService(rdb *redis.Client, hub *ws.Hub, pubsub *ws.PubSub, friendGetter FriendIDsGetter) *OnlineService { - return &OnlineService{ + svc := &OnlineService{ rdb: rdb, hub: hub, pubsub: pubsub, friendGetter: friendGetter, } + svc.cleanStaleOnlineData() + return svc +} + +// cleanStaleOnlineData 服务启动时清理上次残留的在线状态 +// 单实例部署下,启动时不可能有合法的在线用户,直接清空即可 +func (s *OnlineService) cleanStaleOnlineData() { + ctx := context.Background() + funcName := "ws.online_service.cleanStaleOnlineData" + + members, err := s.rdb.SMembers(ctx, onlineSetKey).Result() + if err != nil { + logs.Warn(ctx, funcName, "读取旧在线集合失败", zap.Error(err)) + return + } + if len(members) == 0 { + return + } + + pipe := s.rdb.Pipeline() + pipe.Del(ctx, onlineSetKey) + for _, m := range members { + pipe.Del(ctx, statusKeyPrefix+m) + } + if _, err := pipe.Exec(ctx); err != nil { + logs.Error(ctx, funcName, "清理旧在线数据失败", zap.Error(err)) + } else { + logs.Info(ctx, funcName, "已清理残留在线状态", + zap.Int("stale_count", len(members))) + } } // UserOnline 用户上线:写入 Redis + 通知在线好友 diff --git a/deploy/docker/postgres/init.sql b/deploy/docker/postgres/init.sql index ca7fe37..2152b1e 100644 --- a/deploy/docker/postgres/init.sql +++ b/deploy/docker/postgres/init.sql @@ -186,22 +186,24 @@ CREATE TABLE im_conversation_members ( is_pinned BOOLEAN DEFAULT FALSE, is_deleted BOOLEAN DEFAULT FALSE, unread_count INT DEFAULT 0, - last_read_msg_id BIGINT DEFAULT 0, - created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + last_read_msg_id BIGINT DEFAULT 0, + clear_before_msg_id BIGINT DEFAULT 0, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), UNIQUE (conversation_id, user_id) ); -COMMENT ON TABLE im_conversation_members IS '会话成员表,存储每个成员对会话的个人视图'; -COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识'; -COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID'; -COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID'; -COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶该会话'; -COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删除该会话(软删除,不影响对方视图)'; -COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数'; -COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID'; -COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间'; -COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间'; +COMMENT ON TABLE im_conversation_members IS '会话成员表,存储每个成员对会话的个人视图'; +COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识'; +COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID'; +COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID'; +COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶该会话'; +COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否删除该会话(软删除,不影响对方视图)'; +COMMENT ON COLUMN im_conversation_members.unread_count IS '该成员在此会话中的未读消息数'; +COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '该成员最后已读消息 ID'; +COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空聊天记录时的消息截止 ID(个人视图,不影响对方)'; +COMMENT ON COLUMN im_conversation_members.created_at IS '加入会话时间'; +COMMENT ON COLUMN im_conversation_members.updated_at IS '最后更新时间'; 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); diff --git a/docs/api/README.md b/docs/api/README.md index a58391e..4a2c19d 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -15,7 +15,7 @@ | [frontend/auth.md](frontend/auth.md) | 用户认证 | ✅ Phase 1 | 注册、登录、Token 刷新、个人信息管理 | | [frontend/contact.md](frontend/contact.md) | 联系人 | ✅ Phase 2a | 17 个 API:好友申请/管理、好友分组、黑名单、搜索/推荐、在线状态 | | [frontend/websocket.md](frontend/websocket.md) | WebSocket | ✅ Phase 2a | 前端 WebSocket 连接管理、事件协议、心跳、重连 | -| [frontend/im.md](frontend/im.md) | 即时通讯 | 📋 Phase 2b | 会话列表、消息历史、群聊创建与管理 | +| [frontend/im.md](frontend/im.md) | 即时通讯 | ✅ Phase 2b | 7 个 API:会话列表/置顶/删除/清空、历史消息、全局搜索、未读数 | | [frontend/meeting.md](frontend/meeting.md) | 会议 | 📋 后续 | 即时会议、预约会议、加入/离开、会议列表 | | [frontend/notify.md](frontend/notify.md) | 通知 | 📋 后续 | 通知列表、标记已读 | @@ -34,7 +34,7 @@ | 文档 | 状态 | 说明 | |------|------|------| -| [websocket.md](websocket.md) | ✅ Phase 2a | WebSocket 实时事件协议(联系人通知、在线状态、心跳) | +| [websocket.md](websocket.md) | ✅ Phase 2a/2b | WebSocket 实时事件协议(IM 消息收发/撤回/已读/输入、联系人通知、在线状态、心跳) | --- @@ -194,7 +194,7 @@ docs/api/ │ ├── auth.md # 用户认证 ✅ Phase 1 │ ├── contact.md # 联系人管理(17 个 API) ✅ Phase 2a │ ├── websocket.md # WebSocket 事件协议 ✅ Phase 2a -│ ├── im.md # 即时通讯 📋 Phase 2b +│ ├── im.md # 即时通讯(7 个 API) ✅ Phase 2b │ ├── meeting.md # 会议 📋 后续 │ └── notify.md # 通知 📋 后续 ├── admin/ # 后台管理端 API @@ -204,5 +204,5 @@ docs/api/ │ ├── contact.md # 好友关系管理 ✅ Phase 2a │ ├── meeting.md # 会议管理 📋 后续 │ └── system.md # 系统管理 📋 待定 -└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a +└── websocket.md # WebSocket 全量事件协议 ✅ Phase 2a/2b ``` diff --git a/docs/api/frontend/im.md b/docs/api/frontend/im.md index 2bcadfb..58a1532 100644 --- a/docs/api/frontend/im.md +++ b/docs/api/frontend/im.md @@ -1,8 +1,9 @@ -# 即时通讯模块 API (IM) +# 即时通讯模块 REST API (IM) > 通用规范(认证方式、响应格式、错误码)见 [README.md](../README.md) -> 消息的实时收发通过 WebSocket 完成,见 [websocket.md](../websocket.md) +> 消息的实时收发(发送/撤回/标记已读/正在输入)通过 WebSocket 完成,见 [websocket.md](../websocket.md) > 本文档中的接口用于会话管理和消息历史查询等非实时操作。 +> **最后更新:** 2026-03-03(代码审查修复后同步) --- @@ -10,166 +11,84 @@ | 方法 | 路径 | 权限 | 说明 | |------|------|------|------| -| GET | /api/v1/conversations | 需认证 | 获取会话列表 | -| POST | /api/v1/conversations | 需认证 | 创建群聊 | -| GET | /api/v1/conversations/:id | 需认证 | 获取会话详情 | -| GET | /api/v1/conversations/:id/messages | 需认证 | 获取消息历史 | -| POST | /api/v1/conversations/:id/members | 需认证 | 邀请成员加入群聊 | -| DELETE | /api/v1/conversations/:id/members/:uid | 需认证 | 移除群聊成员 | +| GET | /api/v1/im/conversations | 需认证 | 获取会话列表 | +| GET | /api/v1/im/messages | 需认证 | 获取历史消息(游标分页) | +| PUT | /api/v1/im/conversations/:id/pin | 需认证 | 置顶/取消置顶 | +| DELETE | /api/v1/im/conversations/:id | 需认证 | 删除会话(软删除) | +| DELETE | /api/v1/im/conversations/:id/messages | 需认证 | 清空聊天记录(个人视图) | +| GET | /api/v1/im/messages/search | 需认证 | 全局消息搜索 | +| GET | /api/v1/im/unread | 需认证 | 获取全局未读消息总数 | --- ## 1. 获取会话列表 -`GET /api/v1/conversations` +`GET /api/v1/im/conversations` **权限:** 需认证 -**说明:** 返回当前用户的所有会话,按最后消息时间倒序排列。单聊会话的 `name`/`avatar` 为空,前端应使用 `target_user` 的信息展示。 +**说明:** 返回当前用户的所有会话,排序:置顶优先 → 最后消息时间降序。已软删除的会话不返回。通过 LEFT JOIN 一次获取单聊对方用户 ID,避免 N+1 查询。 **成功响应:** + ```json { "code": 0, - "message": "ok", - "data": [ - { - "id": 1, - "type": 1, - "name": "", - "avatar": "", - "target_user": { - "id": 2, - "nickname": "李四", - "avatar": "https://cdn.echochat.com/avatar/2.jpg", - "online": true - }, - "last_message": { - "id": 100, - "type": 1, - "content": "你好", - "sender_id": 2, - "created_at": "2026-02-27 10:30:00" - }, - "unread_count": 3, - "is_pinned": false - }, - { - "id": 5, - "type": 2, - "name": "产品讨论组", - "avatar": "https://cdn.echochat.com/group/5.jpg", - "target_user": null, - "last_message": { - "id": 205, - "type": 1, - "content": "明天开会", - "sender_id": 3, - "created_at": "2026-02-27 11:00:00" - }, - "unread_count": 0, - "is_pinned": true, - "member_count": 8 - } - ] -} -``` - ---- - -## 2. 创建群聊 - -`POST /api/v1/conversations` - -**权限:** 需认证 - -**请求参数:** - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| name | string | 是 | 群聊名称 | -| member_ids | int[] | 是 | 初始成员用户 ID 列表(不含自己,至少 2 人) | - -**说明:** 创建者自动成为群主(role=2),被邀请的成员为普通成员(role=0)。 - ---- - -## 3. 获取会话详情 - -`GET /api/v1/conversations/:id` - -**权限:** 需认证,且为该会话成员 - -**成功响应(群聊示例):** -```json -{ - "code": 0, - "message": "ok", + "message": "success", "data": { - "id": 5, - "type": 2, - "name": "产品讨论组", - "avatar": "https://cdn.echochat.com/group/5.jpg", - "owner_id": 1, - "max_members": 200, - "member_count": 8, - "members": [ - { "user_id": 1, "nickname": "张三", "role": 2, "online": true }, - { "user_id": 2, "nickname": "李四", "role": 0, "online": false } - ], - "created_at": "2026-02-20 09:00:00" + "list": [ + { + "id": 1, + "type": 1, + "peer_user_id": 2, + "peer_nickname": "李四", + "peer_avatar": "https://...", + "last_msg_content": "你好", + "last_msg_time": "2026-03-03 10:30:00", + "last_msg_sender_id": 2, + "is_pinned": false, + "unread_count": 3 + } + ] } } ``` --- -## 4. 获取消息历史 +## 2. 获取历史消息 -`GET /api/v1/conversations/:id/messages` +`GET /api/v1/im/messages` **权限:** 需认证,且为该会话成员 **查询参数:** -| 参数 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| before_id | int | 无 | 获取此消息 ID 之前的消息(用于向上翻页加载历史) | -| limit | int | 30 | 每次获取数量,最大 50 | +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| conversation_id | int | 是 | - | 会话 ID | +| before_id | int | 否 | 0 | 游标:查询 ID 小于此值的消息,0=最新 | +| limit | int | 否 | 30 | 每次获取数量,最大 100 | + +**说明:** 支持 `clear_before_msg_id` 个人视图过滤(清空聊天记录后,仅过滤当前用户视图,不影响对方)。 **成功响应:** + ```json { "code": 0, - "message": "ok", + "message": "success", "data": { - "messages": [ - { - "id": 98, - "sender_id": 1, - "sender_name": "张三", - "sender_avatar": "https://...", - "type": 1, - "content": "明天几点开会?", - "extra": {}, - "status": 1, - "created_at": "2026-02-27 10:28:00" - }, + "list": [ { "id": 99, + "conversation_id": 1, "sender_id": 2, - "sender_name": "李四", - "sender_avatar": "https://...", - "type": 2, - "content": "", - "extra": { - "url": "https://cdn.echochat.com/img/xxx.jpg", - "width": 800, - "height": 600, - "thumbnail": "https://cdn.echochat.com/img/xxx_thumb.jpg" - }, + "type": 1, + "content": "你好", "status": 1, - "created_at": "2026-02-27 10:29:00" + "client_msg_id": "", + "created_at": "2026-03-03 10:29:00" } ], "has_more": true @@ -179,30 +98,97 @@ --- -## 5. 邀请成员加入群聊 +## 3. 置顶/取消置顶会话 -`POST /api/v1/conversations/:id/members` +`PUT /api/v1/im/conversations/:id/pin` -**权限:** 需认证,且为该群聊成员 +**权限:** 需认证,且为该会话成员 **请求参数:** | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| user_ids | int[] | 是 | 要邀请的用户 ID 列表 | - -**可能的错误码:** 3001(会话不存在),3002(非会话成员),4003(超出人数上限) +| is_pinned | bool | 是 | true=置顶, false=取消 | --- -## 6. 移除群聊成员 +## 4. 删除会话 -`DELETE /api/v1/conversations/:id/members/:uid` +`DELETE /api/v1/im/conversations/:id` -**权限:** 需认证,且为群主或管理员 +**权限:** 需认证,且为该会话成员 -**路径参数:** -- `id` — 会话 ID -- `uid` — 被移除的用户 ID +**说明:** 软删除,仅影响当前用户视图,不影响对方。同时清零未读数并更新 Redis 全局未读。 -**可能的错误码:** 3001, 3002, 1003(非群主/管理员无权操作) +--- + +## 5. 清空聊天记录 + +`DELETE /api/v1/im/conversations/:id/messages` + +**权限:** 需认证,且为该会话成员 + +**说明:** 个人视图操作,不影响对方的消息。实现方式:记录清空时的最后消息 ID(`clear_before_msg_id`),后续查询历史消息时过滤。同时清零该会话未读数。 + +--- + +## 6. 全局消息搜索 + +`GET /api/v1/im/messages/search` + +**权限:** 需认证 + +**查询参数:** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +|------|------|------|--------|------| +| keyword | string | 是 | - | 搜索关键词 | +| limit | int | 否 | 50 | 返回条数上限 | + +**说明:** 使用 PostgreSQL GIN 全文索引(`to_tsvector('simple', content) @@ plainto_tsquery('simple', ?)`),仅搜索用户所在会话的消息。 + +**成功响应:** + +```json +{ + "code": 0, + "message": "success", + "data": { + "list": [ + { + "id": 99, + "conversation_id": 1, + "sender_id": 2, + "type": 1, + "content": "你好世界", + "status": 1, + "created_at": "2026-03-03 10:29:00", + "sender_nickname": "李四", + "sender_avatar": "https://..." + } + ] + } +} +``` + +--- + +## 7. 获取全局未读消息总数 + +`GET /api/v1/im/unread` + +**权限:** 需认证 + +**说明:** 从 Redis STRING 读取全局未读总数,用于 TabBar badge 显示。 + +**成功响应:** + +```json +{ + "code": 0, + "message": "success", + "data": { + "total_unread": 5 + } +} +``` diff --git a/docs/api/websocket.md b/docs/api/websocket.md index 4c29499..a12608d 100644 --- a/docs/api/websocket.md +++ b/docs/api/websocket.md @@ -84,24 +84,38 @@ **方向:** 客户端 → 服务端 -**说明:** 发送消息到会话 +**说明:** 发送消息到会话。`conversation_id` 和 `target_user_id` 二选一:首次发消息使用 `target_user_id`(自动创建会话),后续使用 `conversation_id`。 **data 参数:** | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| -| conversation_id | int | 是 | 目标会话 ID | -| type | int | 是 | 消息类型:1=文本,2=图片,3=文件,4=语音 | -| content | string | 否 | 文本内容 | -| extra | object | 否 | 附加数据(图片/文件信息) | +| conversation_id | int | 否 | 已有会话 ID(与 target_user_id 二选一) | +| target_user_id | int | 否 | 对方用户 ID(首次发消息时使用) | +| type | int | 是 | 消息类型:1=文本 | +| content | string | 是 | 文本内容 | +| client_msg_id | string | 否 | 客户端消息唯一 ID,用于幂等去重 | -**ACK 响应 data:** `{ "msg_id": 10086 }` +**ACK 响应 data:** + +```json +{ + "id": 10086, + "conversation_id": 1, + "sender_id": 1, + "type": 1, + "content": "你好", + "status": 1, + "client_msg_id": "xxxx-xxxx", + "created_at": "2026-03-03 10:30:00" +} +``` --- ### im.message.new -**方向:** 服务端 → 客户端 +**方向:** 服务端 → 客户端(推送) **说明:** 收到新消息推送 @@ -116,50 +130,76 @@ "sender_avatar": "https://...", "type": 1, "content": "你好", - "extra": {}, - "created_at": "2026-02-27 10:30:00" + "client_msg_id": "", + "created_at": "2026-03-03 10:30:00" } ``` --- -### im.message.revoke +### im.message.recall **方向:** 客户端 → 服务端 -**说明:** 撤回消息(发送后 2 分钟内) +**说明:** 撤回消息(发送后 2 分钟内)。撤回成功后若该消息是会话最后一条,会同步更新会话预览为"XX 撤回了一条消息"。 **data 参数:** `{ "message_id": 10086 }` --- -### im.message.read +### im.message.recalled -**方向:** 客户端 → 服务端 +**方向:** 服务端 → 客户端(推送) -**说明:** 消息已读回执 +**说明:** 消息被撤回通知 -**data 参数:** `{ "conversation_id": 1, "message_id": 10086 }` +**data 内容:** `{ "message_id": 10086, "conversation_id": 1, "sender_id": 2 }` --- -### im.typing.start +### im.conversation.read **方向:** 客户端 → 服务端 -**说明:** 通知对方"正在输入" +**说明:** 标记会话已读(清零未读数 + 更新 Redis 全局未读数) **data 参数:** `{ "conversation_id": 1 }` --- -### im.typing.stop +### im.typing -**方向:** 客户端 → 服务端 +**方向:** 双向(客户端发送 → 服务端转发给对方) -**说明:** 停止输入 +**说明:** 正在输入通知。客户端发送后服务端转发给对方,前端收到后设置 3 秒超时自动清除。 -**data 参数:** `{ "conversation_id": 1 }` +**data 参数(客户端发送):** `{ "conversation_id": 1 }` + +**data 内容(服务端推送):** `{ "conversation_id": 1, "user_id": 2 }` + +--- + +### im.offline.sync + +**方向:** 服务端 → 客户端(推送) + +**说明:** WebSocket 连接成功后服务端主动推送离线未读摘要 + +**data 内容:** + +```json +{ + "total_unread": 5, + "conversations": [ + { + "conversation_id": 1, + "unread_count": 3, + "last_msg_content": "你好", + "last_msg_time": "2026-03-03 10:30:00" + } + ] +} +``` --- diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md index 23ddcbc..1ff7b38 100644 --- a/docs/architecture/system-architecture.md +++ b/docs/architecture/system-architecture.md @@ -91,7 +91,7 @@ EchoChat 采用 **「精简单体 + 媒体微服务」** 架构,核心思想 | auth | 用户注册/登录、有状态 JWT Token 管理(Redis 存储 + client_type 隔离)、RBAC 角色权限(level 等级体系:1=超管, 10=管理员, 100=用户) | ✅ Phase 1 | | ws | WebSocket 连接管理(Hub/Client/PubSub)、在线状态管理(Redis SET + TTL 心跳续期)、好友上下线实时通知(FriendIDsGetter 接口注入) | ✅ Phase 2a | | contact | 好友关系管理(申请/接受/拒绝/删除/拉黑)、好友分组(CRUD + 移动)、用户搜索、好友推荐(批量查询优化) | ✅ Phase 2a | -| im | 即时消息收发、会话管理、消息存储 | 🔜 Phase 2b | +| im | 即时消息收发(单聊)、会话管理、消息存储、撤回、搜索、离线推送 | ✅ Phase 2b | | meeting | 会议创建/管理、信令转发、mediasoup 资源编排 | 📋 后续 | | notify | 通知推送、会议邀请、好友申请通知 | 📋 后续 | | admin | 后台管理(用户管理 + 角色权限管理 + 在线监控 + 好友关系管理、会议监控、系统配置) | ✅ Phase 1/2a | @@ -126,7 +126,7 @@ main.go ├── admin.RegisterRoutes(engine, ...) // /api/v1/admin/* ├── contact.RegisterRoutes(engine, ...) // /api/v1/contacts/* ✅ Phase 2a ├── ws.RegisterRoutes(engine, ...) // /ws ✅ Phase 2a - ├── im.RegisterRoutes(engine, ...) // /api/v1/im/* (Phase 2b) + ├── im.RegisterRoutes(engine, ...) // /api/v1/im/* ✅ Phase 2b └── meeting.RegisterRoutes(engine, ...) // /api/v1/meeting/* (后续) ``` @@ -209,7 +209,7 @@ mediasoup C++ SFU 的 **"遥控器"**,不懂业务、不懂用户、只懂媒 | 规则 | 说明 | |------|------| -| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信(实例:ws.FriendIDsGetter 由 contact.FriendshipDAO 实现) | +| 模块间零直接引用 | `auth` 不会 import `im` 内部代码,通过 interface 通信(实例:ws.FriendIDsGetter / im.FriendChecker / contact.OnlineChecker 均通过接口注入) | | 模块自包含路由 | 每个模块在自己目录内维护 `router.go`,拆分时整目录搬走 | | 主路由仅做汇总 | `router/router.go` 只注册各模块路由,不含具体路由定义 | | 数据库表按模块前缀 | `auth_users`、`im_messages`、`meeting_rooms`,后期可分库 | diff --git a/docs/plans/2026-02-27-echochat-system-design.md b/docs/plans/2026-02-27-echochat-system-design.md index 9438ca3..c587359 100644 --- a/docs/plans/2026-02-27-echochat-system-design.md +++ b/docs/plans/2026-02-27-echochat-system-design.md @@ -353,93 +353,101 @@ COMMENT ON COLUMN contact_groups.sort_order IS '排序权重,数值越小越 COMMENT ON COLUMN contact_groups.created_at IS '创建时间'; ``` -#### im 模块 — 即时通讯(统一会话模型) +#### im 模块 — 即时通讯(统一会话模型)✅ Phase 2b 已实现 单聊和群聊统一抽象为"会话",本质都是"一组人在一个空间里收发消息"。这是微信、钉钉、Slack 等主流 IM 的标准模型。 +> **Phase 2b 实际实现说明:** 当前已实现单聊功能,表结构已针对实际需求进行了优化(冗余 last_msg_* 字段提升查询效率,新增 clear_before_msg_id 实现个人视图清空,新增 client_msg_id 实现消息幂等)。群聊相关字段(name/avatar/owner_id/max_members/role/nickname/is_muted)保留在总体设计中,将在 Phase 2c 实现。 + ```sql -- ============================================================ --- im_conversations: 会话表 --- 统一抽象单聊和群聊,单聊时 name/avatar 为空(前端用对方信息展示) +-- im_conversations: 会话表(Phase 2b 实际实现版本) +-- 统一抽象单聊和群聊,含冗余 last_msg_* 字段避免 JOIN 查询 -- ============================================================ CREATE TABLE im_conversations ( - id BIGSERIAL PRIMARY KEY, - type SMALLINT NOT NULL, - name VARCHAR(100) DEFAULT '', - avatar VARCHAR(500) DEFAULT '', - owner_id BIGINT DEFAULT NULL, - max_members INT NOT NULL DEFAULT 200, - status SMALLINT NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP NOT NULL DEFAULT NOW() + id BIGSERIAL PRIMARY KEY, + type SMALLINT NOT NULL DEFAULT 1, + creator_id BIGINT NOT NULL, + last_message_id BIGINT DEFAULT NULL, + 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() ); -COMMENT ON TABLE im_conversations IS '会话表,统一管理单聊和群聊会话'; -COMMENT ON COLUMN im_conversations.id IS '会话唯一标识'; -COMMENT ON COLUMN im_conversations.type IS '会话类型:1=单聊(两人私聊),2=群聊(多人群组)'; -COMMENT ON COLUMN im_conversations.name IS '会话名称,群聊时为群名,单聊时为空(前端取对方昵称展示)'; -COMMENT ON COLUMN im_conversations.avatar IS '会话头像 URL,群聊时为群头像,单聊时为空(前端取对方头像展示)'; -COMMENT ON COLUMN im_conversations.owner_id IS '群主用户 ID,仅群聊时有值,单聊时为 NULL'; -COMMENT ON COLUMN im_conversations.max_members IS '最大成员数,单聊固定为2,群聊默认200'; -COMMENT ON COLUMN im_conversations.status IS '会话状态:1=正常,2=已解散(仅群聊可解散)'; -COMMENT ON COLUMN im_conversations.created_at IS '会话创建时间'; -COMMENT ON COLUMN im_conversations.updated_at IS '最后更新时间'; +COMMENT ON TABLE im_conversations IS '会话表,统一管理单聊和群聊会话'; +COMMENT ON COLUMN im_conversations.id IS '会话唯一标识'; +COMMENT ON COLUMN im_conversations.type IS '会话类型:1=单聊,2=群聊(Phase 2c)'; +COMMENT ON COLUMN im_conversations.creator_id IS '会话创建者用户 ID'; +COMMENT ON COLUMN im_conversations.last_message_id IS '最后一条消息 ID(冗余字段,避免 JOIN)'; +COMMENT ON COLUMN im_conversations.last_msg_content IS '最后消息预览文本(冗余字段)'; +COMMENT ON COLUMN im_conversations.last_msg_time IS '最后消息时间(冗余字段,用于排序)'; +COMMENT ON COLUMN im_conversations.last_msg_sender_id IS '最后消息发送者 ID(冗余字段)'; + +CREATE INDEX idx_im_conversations_updated ON im_conversations(updated_at DESC); -- ============================================================ --- im_conversation_members: 会话成员表 --- 记录每个会话中的参与成员及其个性化设置 +-- im_conversation_members: 会话成员表(Phase 2b 实际实现版本) +-- 记录每个会话中的参与成员及其个人视图设置 -- ============================================================ CREATE TABLE im_conversation_members ( - id BIGSERIAL PRIMARY KEY, - conversation_id BIGINT NOT NULL REFERENCES im_conversations(id), - user_id BIGINT NOT NULL REFERENCES auth_users(id), - role SMALLINT NOT NULL DEFAULT 0, - nickname VARCHAR(50) DEFAULT '', - is_muted BOOLEAN NOT NULL DEFAULT FALSE, - is_pinned BOOLEAN NOT NULL DEFAULT FALSE, - last_read_msg_id BIGINT DEFAULT 0, - joined_at TIMESTAMP NOT NULL DEFAULT NOW(), - UNIQUE (conversation_id, user_id) + id BIGSERIAL PRIMARY KEY, + conversation_id BIGINT NOT NULL REFERENCES im_conversations(id), + user_id BIGINT NOT NULL, + is_pinned BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE, + unread_count INT DEFAULT 0, + last_read_msg_id BIGINT DEFAULT 0, + clear_before_msg_id BIGINT DEFAULT 0, + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + UNIQUE(conversation_id, user_id) ); -COMMENT ON TABLE im_conversation_members IS '会话成员表,记录成员列表及每人的个性化设置'; -COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识'; -COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID'; -COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID'; -COMMENT ON COLUMN im_conversation_members.role IS '成员角色:0=普通成员,1=管理员(群聊可设置),2=群主'; -COMMENT ON COLUMN im_conversation_members.nickname IS '群内昵称,仅在该群聊中生效,为空则使用用户全局昵称'; -COMMENT ON COLUMN im_conversation_members.is_muted IS '是否被禁言:false=正常发言,true=已被禁言(仅管理员/群主可操作)'; -COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶该会话:false=不置顶,true=置顶(个人设置,不影响他人)'; -COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '最后已读消息 ID,用于计算未读消息数'; -COMMENT ON COLUMN im_conversation_members.joined_at IS '加入会话的时间'; +COMMENT ON TABLE im_conversation_members IS '会话成员表,记录成员列表及个人视图设置'; +COMMENT ON COLUMN im_conversation_members.id IS '记录唯一标识'; +COMMENT ON COLUMN im_conversation_members.conversation_id IS '所属会话 ID'; +COMMENT ON COLUMN im_conversation_members.user_id IS '成员用户 ID'; +COMMENT ON COLUMN im_conversation_members.is_pinned IS '是否置顶(个人设置)'; +COMMENT ON COLUMN im_conversation_members.is_deleted IS '是否软删除(个人视图,不影响对方)'; +COMMENT ON COLUMN im_conversation_members.unread_count IS '未读消息数'; +COMMENT ON COLUMN im_conversation_members.last_read_msg_id IS '最后已读消息 ID'; +COMMENT ON COLUMN im_conversation_members.clear_before_msg_id IS '清空记录截止消息 ID(个人视图,查询时过滤 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); -- ============================================================ --- im_messages: 消息表 --- 系统数据量最大的表,存储所有聊天消息内容 +-- im_messages: 消息表(Phase 2b 实际实现版本) +-- 系统数据量最大的表,含 client_msg_id 实现幂等 + GIN 全文搜索索引 -- ============================================================ CREATE TABLE im_messages ( id BIGSERIAL PRIMARY KEY, - conversation_id BIGINT NOT NULL, - sender_id BIGINT NOT NULL, + conversation_id BIGINT NOT NULL REFERENCES im_conversations(id), + sender_id BIGINT NOT NULL, type SMALLINT NOT NULL DEFAULT 1, - content TEXT NOT NULL DEFAULT '', - extra JSONB DEFAULT '{}', + content TEXT NOT NULL, + extra JSONB DEFAULT NULL, status SMALLINT NOT NULL DEFAULT 1, - created_at TIMESTAMP NOT NULL DEFAULT NOW() + client_msg_id VARCHAR(64) DEFAULT '', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); -COMMENT ON TABLE im_messages IS '聊天消息表,存储所有会话的消息记录(系统数据量最大的表)'; +COMMENT ON TABLE im_messages IS '聊天消息表,存储所有会话的消息记录'; COMMENT ON COLUMN im_messages.id IS '消息唯一标识,全局自增'; COMMENT ON COLUMN im_messages.conversation_id IS '所属会话 ID'; COMMENT ON COLUMN im_messages.sender_id IS '发送者用户 ID'; -COMMENT ON COLUMN im_messages.type IS '消息类型:1=文本消息,2=图片消息,3=文件消息,4=语音消息,5=系统通知消息'; -COMMENT ON COLUMN im_messages.content IS '消息内容,文本消息为文字,其他类型为描述文字或为空'; -COMMENT ON COLUMN im_messages.extra IS '附加数据(JSON),图片消息存 {url,width,height},文件消息存 {url,name,size},语音消息存 {url,duration}'; -COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回(发送者撤回),3=已删除(管理员删除)'; +COMMENT ON COLUMN im_messages.type IS '消息类型:1=文本(预留 2=图片 3=语音 4=文件 5=系统通知)'; +COMMENT ON COLUMN im_messages.content IS '消息内容'; +COMMENT ON COLUMN im_messages.extra IS '附加数据(JSON),预留扩展'; +COMMENT ON COLUMN im_messages.status IS '消息状态:1=正常,2=已撤回,3=已删除'; +COMMENT ON COLUMN im_messages.client_msg_id IS '客户端消息唯一 ID,用于幂等去重'; COMMENT ON COLUMN im_messages.created_at IS '消息发送时间'; CREATE INDEX idx_im_messages_conv_time ON im_messages(conversation_id, created_at DESC); -COMMENT ON INDEX idx_im_messages_conv_time IS '会话消息时间索引,用于按时间倒序查询会话历史消息'; +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)); ``` #### meeting 模块 — 音视频会议 @@ -586,9 +594,8 @@ echo:auth:refresh:{client_type}:{user_id} → Refresh Token (STRING, TTL 由 echo:user:online → 在线用户集合 (SET) echo:user:status:{user_id} → 用户状态 JSON (STRING, TTL 自动过期) -# 即时通讯 -echo:im:unread:{user_id} → 各会话未读数 (HASH: conv_id → count) -echo:im:typing:{conversation_id} → 正在输入的用户 (SET, TTL 5秒) +# 即时通讯(Phase 2b 实际实现) +echo:im:unread:{user_id} → 全局未读消息总数 (STRING,Lua 脚本原子递减,下限 0) # 会议实时状态 echo:meeting:room:{room_code} → 房间实时状态 JSON (STRING) @@ -702,13 +709,14 @@ GET /api/v1/contacts/recommend 好友推荐 # 在线状态 GET /api/v1/contacts/online 批量查询好友在线状态 -# 即时通讯模块 -GET /api/v1/conversations -POST /api/v1/conversations -GET /api/v1/conversations/:id -GET /api/v1/conversations/:id/messages -POST /api/v1/conversations/:id/members -DELETE /api/v1/conversations/:id/members/:uid +# 即时通讯模块(Phase 2b 已实现) +GET /api/v1/im/conversations 会话列表(含未读数、对方信息) +GET /api/v1/im/messages 历史消息(游标分页 before_id + limit) +PUT /api/v1/im/conversations/:id/pin 置顶/取消置顶 +DELETE /api/v1/im/conversations/:id 删除会话(软删除) +DELETE /api/v1/im/conversations/:id/messages 清空聊天记录(个人视图) +GET /api/v1/im/messages/search 全局消息搜索(GIN 全文索引) +GET /api/v1/im/unread 全局未读消息总数 # 会议模块 POST /api/v1/meetings @@ -811,9 +819,11 @@ pages/ │ ├── register.vue # 注册页 │ └── forgot-password.vue # 忘记密码 ├── chat/ -│ ├── index.vue # 会话列表 -│ ├── conversation.vue # 聊天对话页 -│ └── group-create.vue # 创建群聊 +│ ├── index.vue # 会话列表 ✅ Phase 2b +│ ├── conversation.vue # 聊天对话页 ✅ Phase 2b +│ ├── settings.vue # 聊天设置页 ✅ Phase 2b +│ ├── search.vue # 消息搜索页 ✅ Phase 2b +│ └── group-create.vue # 创建群聊 📋 Phase 2c ├── contact/ │ ├── index.vue # 联系人列表(含搜索/在线状态) ✅ Phase 2a │ ├── search.vue # 搜索添加好友 + 好友推荐 ✅ Phase 2a @@ -937,10 +947,25 @@ services: - 在线状态管理(混合推拉方案) - 管理端扩展(在线监控 + 好友关系管理) -#### Phase 2b:即时通讯消息系统 🔜 待开发 -- 即时聊天(单聊 + 群聊,文字/图片/文件) +#### Phase 2b:即时通讯消息系统 ✅ 已完成 +- 单聊即时通讯(WebSocket 全双工消息收发、三态 ACK 确认) +- 会话管理(自动创建、置顶、软删除、清空聊天记录 — 个人视图) +- 消息撤回(2 分钟内)、正在输入提示 +- 离线消息推送(WS 连接后服务端主动推送未读摘要) +- 未读消息管理(会话 badge + TabBar 总未读数,Lua 脚本原子操作) +- 全局消息搜索(PostgreSQL GIN 全文索引) +- WS 事件路由表机制(Hub.RegisterEvent/DispatchEvent) +- 前台 4 个页面 + chat Store + API 封装 +- 设计文档:`docs/plans/2026-03-03-phase2b-design.md` -#### Phase 2c:会议与通知 📋 待规划 +#### Phase 2c:群聊与增强 📋 待规划 +- 群聊会话(建群/加入/退出/管理) +- 群消息收发 +- 已读回执(单聊 + 群聊) +- 消息类型扩展(图片/语音/文件) +- 管理端消息管理功能 + +#### Phase 2d:会议与通知 📋 待规划 - 多人音视频会议(即时会议 + 预约会议) - 消息通知系统 diff --git a/docs/plans/2026-03-03-phase2b-design.md b/docs/plans/2026-03-03-phase2b-design.md index 9f2c3e8..fe9ff5f 100644 --- a/docs/plans/2026-03-03-phase2b-design.md +++ b/docs/plans/2026-03-03-phase2b-design.md @@ -1,10 +1,10 @@ # Phase 2b 设计文档:即时通讯消息系统(单聊) -> **状态:** 📋 设计完成,待实施 +> **状态:** ✅ 已完成(含代码审查修复) > **分支:** `feature/phase2b-instant-messaging` > **前置依赖:** Phase 2a 全部完成(WebSocket + 联系人管理) > **架构备忘:** `docs/plans/2026-03-02-phase2b-architecture-notes.md` -> **最后更新:** 2026-03-03 +> **最后更新:** 2026-03-03(含代码审查修复 7 项 + 用户测试修复 8 项 + 文档同步) --- @@ -98,7 +98,7 @@ func (h *Hub) RegisterEvent(event string, handler EventHandler) 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`。 +IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.recall`、`im.conversation.read`、`im.typing`。 ### 3.3 离线消息推送 @@ -117,14 +117,14 @@ IM 模块在启动时向 Hub 注册事件:`im.message.send`、`im.message.reca ``` 发送方 Client - │ WS: im.message.recall { message_id, conversation_id } + │ WS: im.message.recall { message_id } ▼ IM Service.RecallMessage() ├── 校验:是否为自己的消息、是否在 2 分钟内 ├── DB: UPDATE im_messages SET status = 2(已撤回) - ├── DB: UPDATE im_conversations (last_msg_content = "XX 撤回了一条消息") + ├── 若被撤回的是会话最后一条消息 → UPDATE im_conversations (last_msg_content = "XX 撤回了一条消息") ├── WS Response: im.message.recall.ack { message_id } - └── PubSub.PublishToUser(receiverID, im.message.recalled) + └── PubSub.PublishToUser(receiverID, im.message.recalled { message_id, conversation_id, sender_id }) ``` ### 3.5 消息收发时序图 @@ -198,15 +198,16 @@ CREATE INDEX idx_im_conversations_updated ON im_conversations(updated_at DESC); ```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(), + 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 + clear_before_msg_id BIGINT DEFAULT 0, -- 清空记录截止消息 ID(个人视图,不影响对方) + created_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP(0) NOT NULL DEFAULT NOW(), UNIQUE(conversation_id, user_id) ); @@ -215,6 +216,8 @@ CREATE INDEX idx_im_conv_members_user ON im_conversation_members(user_id, is_del CREATE INDEX idx_im_conv_members_conv ON im_conversation_members(conversation_id); ``` +> **设计说明**:`clear_before_msg_id` 用于实现"清空聊天记录"的个人视图。用户 A 清空记录后,仅 A 看不到清空前的消息,用户 B 不受影响。查询历史消息时增加 `id > clear_before_msg_id` 过滤条件。 + ### 4.3 im_messages(消息表) ```sql @@ -253,11 +256,13 @@ LIMIT 1 | Key | 类型 | 说明 | |-----|------|------| -| `echo:im:unread:{user_id}` | HASH | 每个会话的未读数 `{ conv_id: count }` | +| `echo:im:unread:{user_id}` | STRING | 全局未读消息总数(会话级别的未读数存储在 DB `im_conversation_members.unread_count`) | -- 收到新消息:`HINCRBY echo:im:unread:{user_id} {conv_id} 1` -- 标记已读:`HDEL echo:im:unread:{user_id} {conv_id}` -- 获取总未读数:`HVALS echo:im:unread:{user_id}` 求和 +- 收到新消息:`INCR echo:im:unread:{user_id}`(全局 +1) +- 标记已读/清空/删除会话:Lua 脚本原子递减(下限为 0,防止负数) +- 获取总未读数:`GET echo:im:unread:{user_id}` + +> **设计说明**:选择 STRING 而非 HASH 的原因:DB 已有会话级别的 `unread_count` 字段,Redis 仅用于 TabBar badge 的快速读取,不需要冗余存储每个会话的未读数。Lua 脚本保证递减操作的原子性和下限保护。 --- @@ -267,11 +272,12 @@ LIMIT 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 }` | +| `im.message.send` | 发送消息 | `{ conversation_id, target_user_id, content, type, client_msg_id }` | +| `im.message.recall` | 撤回消息 | `{ message_id }` | +| `im.conversation.read` | 标记已读 | `{ conversation_id }` | +| `im.typing` | 正在输入 | `{ conversation_id }` | + +> **说明**:`im.message.send` 中 `conversation_id` 和 `target_user_id` 二选一。首次发消息时使用 `target_user_id`(自动创建会话),后续使用 `conversation_id`。 ### 5.2 服务端 → 客户端(ACK 响应) @@ -288,10 +294,12 @@ LIMIT 1 | 事件 | 说明 | Data 字段 | |------|------|-----------| -| `im.message.new` | 新消息推送 | `{ message_id, conversation_id, sender_id, sender_name, sender_avatar, type, content, created_at }` | +| `im.message.new` | 新消息推送 | `{ id, conversation_id, sender_id, sender_name, sender_avatar, type, content, client_msg_id, 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 }] }` | +| `im.typing` | 正在输入通知 | `{ conversation_id, user_id }` | +| `im.offline.sync` | 离线未读摘要 | `{ total_unread, conversations: [{ conversation_id, unread_count, last_msg_content, last_msg_time }] }` | + +> **说明**:正在输入通知前端收到后设置 3 秒超时自动清除。离线同步事件在 WS 连接成功后由服务端主动推送。 --- @@ -302,14 +310,14 @@ LIMIT 1 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/conversations` | 获取会话列表(含未读数、最后消息、对方信息) | -| GET | `/conversations/:id/messages` | 获取历史消息(游标分页:`before_id` + `limit`) | +| GET | `/messages?conversation_id=xx&before_id=xx&limit=30` | 获取历史消息(游标分页) | | PUT | `/conversations/:id/pin` | 置顶/取消置顶 | | DELETE | `/conversations/:id` | 删除会话(软删除) | -| DELETE | `/conversations/:id/messages` | 清空聊天记录 | -| PUT | `/conversations/:id/read` | 标记会话已读(清零未读数 + 清 Redis) | -| GET | `/messages/search?keyword=xxx` | 全局消息搜索(结果按会话分组) | +| DELETE | `/conversations/:id/messages` | 清空聊天记录(个人视图,不影响对方) | +| GET | `/messages/search?keyword=xxx&limit=50` | 全局消息搜索(GIN 全文索引) | +| GET | `/unread` | 获取全局未读消息总数 | -共 **7 个 REST API**,均需 JWT 认证。 +共 **7 个 REST API**,均需 JWT 认证。标记已读通过 WS 事件 `im.conversation.read` 实现。 --- @@ -363,7 +371,7 @@ Wire 注入链: | `GetMessages(ctx, userID, conversationID, beforeID, limit)` | 获取历史消息(游标分页) | | `PinConversation(ctx, userID, conversationID, isPinned)` | 置顶/取消置顶 | | `DeleteConversation(ctx, userID, conversationID)` | 删除会话(软删除) | -| `ClearMessages(ctx, userID, conversationID)` | 清空聊天记录 | +| `ClearMessages(ctx, userID, conversationID)` | 清空聊天记录(个人视图,ClearBeforeMsgID) | | `MarkAsRead(ctx, userID, conversationID)` | 标记已读 | | `SearchMessages(ctx, userID, keyword)` | 全局搜索 | | `PushOfflineMessages(ctx, userID)` | 推送离线未读摘要 | @@ -406,8 +414,8 @@ frontend/src/pages/chat/ **WS 事件监听:** - `im.message.new` → 更新 conversations + messages + totalUnread - `im.message.recalled` → 更新消息状态为"已撤回" -- `im.typing.notify` → 更新 typingUsers -- `im.conversation.unread` → 初始化未读数 + 会话列表 +- `im.typing` → 更新 typingUsers(3 秒自动清除) +- `im.offline.sync` → 初始化未读数 + 刷新会话列表 ### 8.3 API 封装(api/chat.js) diff --git a/docs/progress/CURRENT_STATUS.md b/docs/progress/CURRENT_STATUS.md index 28e4879..ac2d3d3 100644 --- a/docs/progress/CURRENT_STATUS.md +++ b/docs/progress/CURRENT_STATUS.md @@ -1,7 +1,7 @@ # EchoChat 项目开发进度 -> **最后更新**:2026-03-03(Phase 2b 即时通讯核心 + ui-ux-pro-max 规范改造完成) -> **当前阶段**:Phase 2b 已完成 +> **最后更新**:2026-03-03(Phase 2b 全部完成,含代码审查修复 + 用户测试修复) +> **当前阶段**:Phase 2b 全部完成,准备进入 Phase 2c > **当前分支**:`feature/phase2b-instant-messaging` > **实施计划**:`docs/plans/2026-03-03-phase2b-implementation.plan.md` > **设计文档**:`docs/plans/2026-03-03-phase2b-design.md` @@ -23,6 +23,33 @@ | Task 8 | 设置页 + 搜索页 + 联系人改造 | ✅ 完成 | 2 个辅助页面 + 发消息跳转 | | Task 9 | 文档更新 + 代码审查 | ✅ 完成 | 进度/架构文档同步 | | UI 改造 | ui-ux-pro-max 规范改造 | ✅ 完成 | uni-icons 替换 emoji + 设计规范文件 | +| 代码审查修复 | 后端 7 项修复 | ✅ 完成 | P0×2 + P1×3 + 推送补全×2 | +| 用户测试修复 | 8 项 Bug 修复 | ✅ 完成 | 好友申请/接受、在线状态、UI 布局等 | + +### 代码审查修复详情 + +| # | 优先级 | 修复内容 | +|---|--------|----------| +| Fix 1 | P0 | ClearHistory 改为个人视图操作(ClearBeforeMsgID),不再删除双方消息 | +| Fix 2 | P0 | Redis 未读数负数保护(Lua 脚本原子递减,下限为 0) | +| Fix 3 | P1 | GetConversationList N+1 查询优化(LEFT JOIN 一次获取 peerID) | +| Fix 4 | P1 | 消息搜索改用 GIN 全文索引(to_tsvector/plainto_tsquery 替代 LIKE) | +| Fix 5 | P1 | 撤回消息后更新会话预览(last_msg_content = "XX 撤回了一条消息") | +| Fix 6 | - | im.message.new 推送补充 sender_name、sender_avatar | +| Fix 7 | - | im.message.recalled 推送补充 sender_id | + +### 用户测试修复详情 + +| # | 修复内容 | +|---|----------| +| Fix 8 | 好友申请拒绝后重新申请失败 — FriendshipDAO 新增 ReactivateRejectedRequest 方法 | +| Fix 9 | 好友接受申请失败(反向记录 UNIQUE 冲突)— AcceptRequest 先查后改,避免重复插入 | +| Fix 10 | Redis 在线状态残留 — OnlineService 启动时清理旧在线数据(cleanStaleOnlineData) | +| Fix 11 | WS 断开时在线状态未清理 — 修正 onDisconnect 判断条件(closedByHub && isOnline) | +| Fix 12 | 前端 WS 连接未全局初始化 — App.vue onLaunch/onShow + login.vue 登录后建立连接 | +| Fix 13 | 后台管理端好友关系页用户 A 列名称错误 — 修正字段绑定 row.user_username | +| Fix 14 | 前台好友在线状态初始值缺失 — ContactService 注入 OnlineChecker,GetFriendList 返回 is_online | +| Fix 15 | 聊天页消息过多时输入框被挤出 — scroll-view 添加 height:0 + min-height:0 约束 | --- @@ -37,9 +64,9 @@ ### 会话管理 - **自动创建**:首次发消息时自动创建单聊会话 -- **会话列表**:置顶优先 → 最后消息时间降序,冗余 last_msg_* 避免 JOIN -- **会话操作**:置顶/取消、软删除(不影响对方)、清空聊天记录 -- **未读管理**:DB unread_count + Redis 全局未读数,TabBar badge 显示 +- **会话列表**:置顶优先 → 最后消息时间降序,LEFT JOIN 一次获取 peerID(N+1 优化) +- **会话操作**:置顶/取消、软删除(不影响对方)、清空聊天记录(个人视图 ClearBeforeMsgID) +- **未读管理**:DB unread_count + Redis STRING 全局未读数(Lua 脚本负数保护),TabBar badge 显示 ### WebSocket 事件路由表 - **Hub.RegisterEvent**:业务模块注册事件处理器 @@ -212,8 +239,9 @@ cd frontend && npm run dev:h5 ## 八、下一阶段规划 -### Phase 2c - 群聊与增强 -- 群聊会话(建群/加入/退出) +### Phase 2c - 群聊与增强(待规划) +- 群聊会话(建群/加入/退出/管理) - 群消息收发 -- 已读回执 +- 已读回执(单聊 + 群聊) - 消息类型扩展(图片/语音/文件) +- 管理端消息管理功能 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 729b914..ec73f9c 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,14 +1,44 @@ diff --git a/frontend/src/pages/auth/login.vue b/frontend/src/pages/auth/login.vue index 17b5387..0f39325 100644 --- a/frontend/src/pages/auth/login.vue +++ b/frontend/src/pages/auth/login.vue @@ -90,6 +90,9 @@ * 登录成功后 reLaunch 到首页 */ import { useUserStore } from '@/store/user' +import { useWebSocketStore } from '@/store/websocket' +import { useChatStore } from '@/store/chat' +import { useContactStore } from '@/store/contact' export default { name: 'LoginPage', @@ -152,6 +155,10 @@ export default { account: this.form.account.trim(), password: this.form.password }) + const wsStore = useWebSocketStore() + wsStore.connect() + useChatStore().initWsListeners() + useContactStore().initWsListeners() uni.showToast({ title: '登录成功', icon: 'success' }) setTimeout(() => uni.reLaunch({ url: '/pages/index/index' }), 800) } catch (e) { diff --git a/frontend/src/pages/chat/conversation.vue b/frontend/src/pages/chat/conversation.vue index e369c81..cbf8dd2 100644 --- a/frontend/src/pages/chat/conversation.vue +++ b/frontend/src/pages/chat/conversation.vue @@ -222,8 +222,13 @@ export default { finally { loadingMore.value = false } } + const canRecall = (msg) => { + if (!msg.created_at) return false + return (Date.now() - new Date(msg.created_at).getTime()) < 2 * 60 * 1000 + } + const onMsgLongPress = (msg) => { - if (!isSelf(msg) || msg.status === 2) return + if (!isSelf(msg) || msg.status === 2 || !canRecall(msg)) return uni.showActionSheet({ itemList: ['撤回'], success: (res) => { @@ -304,9 +309,12 @@ export default { /* ===== 消息列表 ===== */ .msg-list { flex: 1; + height: 0; + min-height: 0; padding: 16rpx 24rpx; box-sizing: border-box; width: 100%; + overflow: hidden; } .load-more { text-align: center; padding: 16rpx 0; } .load-more-text { font-size: 24rpx; color: #94A3B8; } diff --git a/frontend/src/pages/chat/search.vue b/frontend/src/pages/chat/search.vue index d7b0cc9..fd6816d 100644 --- a/frontend/src/pages/chat/search.vue +++ b/frontend/src/pages/chat/search.vue @@ -21,6 +21,9 @@ focus @confirm="onSearch" /> + + + 取消 @@ -29,28 +32,35 @@ - - - 未找到相关消息 + + 搜索中... - - - - - - {{ (item.sender_nickname || '?')[0] }} + + 未找到相关消息 + + + + + + + + + {{ (group.peer_name || '?')[0] }} + + {{ group.peer_name || '未知用户' }} + {{ group.messages.length }} 条相关 - - {{ item.sender_nickname || '未知用户' }} - {{ item.content }} - {{ item.created_at }} + + {{ msg.sender_nickname || '未知' }}: + {{ msg.content }} + {{ msg.created_at }} @@ -58,32 +68,66 @@ @@ -127,12 +164,8 @@ export default { height: 68rpx; gap: 12rpx; } - -.search-input { - flex: 1; - font-size: 28rpx; - color: #1E293B; -} +.search-input { flex: 1; font-size: 28rpx; color: #1E293B; } +.search-clear { min-width: 44rpx; min-height: 44rpx; display: flex; align-items: center; justify-content: center; } .search-cancel { margin-left: 20rpx; @@ -142,92 +175,42 @@ export default { align-items: center; justify-content: center; } +.search-cancel-text { font-size: 28rpx; color: #2563EB; } +.search-cancel:active { opacity: 0.6; } -.search-cancel-text { - font-size: 28rpx; - color: #2563EB; -} - -.search-cancel:active { - opacity: 0.6; -} - -.result-list { - height: calc(100vh - 100rpx); +.result-list { height: calc(100vh - 100rpx); } + +/* 分组 */ +.result-group { margin-bottom: 16rpx; } +.group-header { + display: flex; + align-items: center; + padding: 20rpx 32rpx; + background-color: #FFFFFF; + border-bottom: 1rpx solid #E2E8F0; } +.group-header:active { background-color: #F1F5F9; } +.group-avatar-wrap { flex-shrink: 0; margin-right: 16rpx; } +.group-avatar { width: 64rpx; height: 64rpx; border-radius: 16rpx; } +.group-avatar-placeholder { background-color: #2563EB; display: flex; align-items: center; justify-content: center; } +.avatar-text { color: #FFFFFF; font-size: 26rpx; font-weight: 600; } +.group-name { flex: 1; font-size: 30rpx; font-weight: 500; color: #1E293B; } +.group-count { font-size: 24rpx; color: #94A3B8; } +/* 消息条目 */ .result-item { display: flex; align-items: center; - padding: 24rpx 32rpx; + padding: 16rpx 32rpx 16rpx 112rpx; background-color: #FFFFFF; - border-bottom: 1rpx solid #F1F5F9; - transition: background-color 150ms ease; + border-bottom: 1rpx solid #F8FAFC; } +.result-item:active { background-color: #F1F5F9; } +.result-sender { font-size: 26rpx; color: #64748B; flex-shrink: 0; } +.result-content { flex: 1; font-size: 26rpx; color: #1E293B; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.result-time { font-size: 22rpx; color: #94A3B8; margin-left: 12rpx; flex-shrink: 0; } -.result-item:active { - background-color: #F1F5F9; -} - -.result-avatar-wrap { - flex-shrink: 0; - margin-right: 20rpx; -} - -.result-avatar { - width: 80rpx; - height: 80rpx; - border-radius: 20rpx; -} - -.result-avatar-placeholder { - background-color: #2563EB; - display: flex; - align-items: center; - justify-content: center; -} - -.avatar-text { - color: #FFFFFF; - font-size: 28rpx; - font-weight: 600; -} - -.result-info { - flex: 1; - overflow: hidden; -} - -.result-name { - font-size: 28rpx; - font-weight: 500; - color: #1E293B; - margin-bottom: 4rpx; -} - -.result-content { - font-size: 26rpx; - color: #64748B; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - margin-bottom: 4rpx; -} - -.result-time { - font-size: 22rpx; - color: #94A3B8; -} - -.empty-state { - display: flex; - align-items: center; - justify-content: center; - padding-top: 200rpx; -} - -.empty-text { - font-size: 28rpx; - color: #94A3B8; -} +/* 状态 */ +.status-state { display: flex; align-items: center; justify-content: center; padding-top: 200rpx; } +.status-text { font-size: 28rpx; color: #94A3B8; } diff --git a/frontend/src/pages/chat/settings.vue b/frontend/src/pages/chat/settings.vue index a4d5efc..856e77b 100644 --- a/frontend/src/pages/chat/settings.vue +++ b/frontend/src/pages/chat/settings.vue @@ -67,9 +67,13 @@ export default { const togglePin = async () => { const newVal = !isPinned.value - await chatStore.pinConversation(conversationId.value, newVal) - isPinned.value = newVal - uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' }) + try { + await chatStore.pinConversation(conversationId.value, newVal) + isPinned.value = newVal + uni.showToast({ title: newVal ? '已置顶' : '已取消置顶', icon: 'none' }) + } catch { + uni.showToast({ title: '操作失败', icon: 'none' }) + } } const onClearHistory = () => { @@ -78,8 +82,12 @@ export default { content: '确定清空聊天记录?此操作不可恢复', success: async (res) => { if (res.confirm) { - await chatStore.clearHistory(conversationId.value) - uni.showToast({ title: '已清空', icon: 'none' }) + try { + await chatStore.clearHistory(conversationId.value) + uni.showToast({ title: '已清空', icon: 'none' }) + } catch { + uni.showToast({ title: '清空失败', icon: 'none' }) + } } } }) @@ -91,8 +99,12 @@ export default { content: '确定删除该会话?', success: async (res) => { if (res.confirm) { - await chatStore.deleteConversation(conversationId.value) - uni.navigateBack({ delta: 2 }) + try { + await chatStore.deleteConversation(conversationId.value) + uni.navigateBack({ delta: 2 }) + } catch { + uni.showToast({ title: '删除失败', icon: 'none' }) + } } } }) diff --git a/frontend/src/pages/contact/request.vue b/frontend/src/pages/contact/request.vue index cc5688a..fd29ad1 100644 --- a/frontend/src/pages/contact/request.vue +++ b/frontend/src/pages/contact/request.vue @@ -97,6 +97,7 @@ const handleAccept = async (requestId) => { uni.showToast({ title: '已接受', icon: 'success' }) } catch (e) { console.error('接受好友申请失败', e) + uni.showToast({ title: e?.data?.message || '接受申请失败', icon: 'none' }) } finally { processingMap[requestId] = false } @@ -110,6 +111,7 @@ const handleReject = async (requestId) => { uni.showToast({ title: '已拒绝', icon: 'none' }) } catch (e) { console.error('拒绝好友申请失败', e) + uni.showToast({ title: e?.data?.message || '拒绝申请失败', icon: 'none' }) } finally { processingMap[requestId] = false } diff --git a/frontend/src/pages/contact/search.vue b/frontend/src/pages/contact/search.vue index 5a60f0b..559063f 100644 --- a/frontend/src/pages/contact/search.vue +++ b/frontend/src/pages/contact/search.vue @@ -37,7 +37,7 @@ @@ -53,10 +53,10 @@ - {{ addingMap[user.user_id] ? '已发送' : '添加' }} + {{ addingMap[user.id] ? '已发送' : '添加' }} @@ -79,7 +79,7 @@ @@ -91,10 +91,10 @@ - {{ addingMap[user.user_id] ? '已发送' : '添加' }} + {{ addingMap[user.id] ? '已发送' : '添加' }} @@ -141,7 +141,7 @@ const doSearch = async () => { } const showAddDialog = (user) => { - if (addingMap[user.user_id]) return + if (addingMap[user.id]) return uni.showModal({ title: '添加好友', editable: true, @@ -149,13 +149,17 @@ const showAddDialog = (user) => { content: '', success: async (res) => { if (res.confirm) { - addingMap[user.user_id] = true + addingMap[user.id] = true try { - await contactStore.sendRequest(user.user_id, res.content || '') + await contactStore.sendRequest(user.id, res.content || '') uni.showToast({ title: '申请已发送', icon: 'success' }) } catch (e) { console.error('发送好友申请失败', e) - addingMap[user.user_id] = false + addingMap[user.id] = false + uni.showToast({ + title: e?.data?.message || '发送申请失败', + icon: 'none' + }) } } } diff --git a/frontend/src/store/chat.js b/frontend/src/store/chat.js index 74362f7..badb1b7 100644 --- a/frontend/src/store/chat.js +++ b/frontend/src/store/chat.js @@ -42,9 +42,11 @@ export const useChatStore = defineStore('chat', () => { // ==================== Computed ==================== - /** 当前会话的消息列表 */ + /** 当前会话的消息列表(conversationId 为 null 时取 pending 队列) */ const currentMessages = computed(() => { - if (!currentConversationId.value) return [] + if (!currentConversationId.value) { + return messagesMap.value['pending'] || [] + } return messagesMap.value[currentConversationId.value] || [] }) @@ -98,9 +100,8 @@ export const useChatStore = defineStore('chat', () => { _sending: true } - if (conversationId) { - _appendMessage(conversationId, tempMsg) - } + const displayConvId = conversationId || 'pending' + _appendMessage(displayConvId, tempMsg) pendingMessages.value[clientMsgId] = { status: 'sending', tempMsg } @@ -226,12 +227,16 @@ export const useChatStore = defineStore('chat', () => { created_at: data.created_at }) - _updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id) + const existingConv = conversationList.value.find(c => c.id === data.conversation_id) + if (existingConv) { + _updateConversationPreview(data.conversation_id, data.content, data.created_at, data.sender_id) + } else { + fetchConversations() + } if (data.conversation_id !== currentConversationId.value) { - const conv = conversationList.value.find(c => c.id === data.conversation_id) - if (conv) { - conv.unread_count = (conv.unread_count || 0) + 1 + if (existingConv) { + existingConv.unread_count = (existingConv.unread_count || 0) + 1 } totalUnread.value++ } else { @@ -268,6 +273,12 @@ export const useChatStore = defineStore('chat', () => { if (pending.tempMsg && pending.tempMsg.conversation_id === 0 && convId) { currentConversationId.value = convId + const pendingMsgs = messagesMap.value['pending'] || [] + if (pendingMsgs.length > 0) { + if (!messagesMap.value[convId]) messagesMap.value[convId] = [] + messagesMap.value[convId].push(...pendingMsgs) + delete messagesMap.value['pending'] + } } const messages = messagesMap.value[convId] @@ -312,12 +323,19 @@ export const useChatStore = defineStore('chat', () => { // ==================== 内部工具方法 ==================== - /** 追加消息到缓存 */ + /** 追加消息到缓存(带去重检查,防止重连/ACK 导致重复) */ const _appendMessage = (conversationId, message) => { if (!messagesMap.value[conversationId]) { messagesMap.value[conversationId] = [] } - messagesMap.value[conversationId].push(message) + const list = messagesMap.value[conversationId] + const isDup = list.some(m => + (message.id && m.id === message.id) || + (message.client_msg_id && m.client_msg_id === message.client_msg_id) + ) + if (!isDup) { + list.push(message) + } } /** 更新会话列表中的预览信息 */ diff --git a/frontend/src/store/user.js b/frontend/src/store/user.js index 02eb708..a2e2e11 100644 --- a/frontend/src/store/user.js +++ b/frontend/src/store/user.js @@ -14,6 +14,7 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import authApi from '@/api/auth' +import { useWebSocketStore } from '@/store/websocket' import { saveToken, getToken, @@ -112,10 +113,11 @@ export const useUserStore = defineStore('user', () => { * 3. 重置 Store 状态 */ const logout = async () => { + const wsStore = useWebSocketStore() + wsStore.disconnect() try { await authApi.logout() } catch (e) { - // 即使后端请求失败,也要清除本地状态(避免用户被卡住) console.warn('登出 API 调用失败,仍然清除本地状态', e) } token.value = ''