#{{ row.user_id }}
- {{ row.username || '--' }}
+ {{ row.user_username || '--' }}
@@ -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"
/>
+