feat:单聊页面bug修复+后台管理端好友页面bug修复+项目进度、记忆文件更新

This commit is contained in:
bujinyuan
2026-03-04 10:03:16 +08:00
parent 7b1d061955
commit 9399f86a19
30 changed files with 838 additions and 530 deletions

View File

@@ -16,9 +16,12 @@ alwaysApply: true
- **Phase 1基础设施与用户认证**:✅ 全部完成11 个 Task
- **Phase 2aWebSocket 实时通讯与联系人管理)**:✅ 全部完成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.FriendshipDAOim.OfflineMessagePusher → ws.Handler
- 代码审查修复ClearHistory 个人视图、Redis 负数保护、N+1 查询优化、全文索引搜索、撤回更新预览、推送补全 sender 信息
- 用户测试修复:好友申请/接受唯一约束、Redis 在线状态残留清理、WS 全局初始化、管理端字段修正、好友列表在线状态初始值、聊天页布局约束
- **Phase 2c群聊与增强**:待规划设计
- **跨模块通信模式**已建立接口注入标准ws.FriendIDsGetter / im.FriendChecker / im.UserInfoGetter → contact.FriendshipDAOim.OfflineMessagePusher → ws.Handlercontact.OnlineChecker → ws.OnlineService
## 项目概述

View File

@@ -32,7 +32,7 @@
<template #default="{ row }">
<div class="user-cell">
<span class="user-id">#{{ row.user_id }}</span>
<span class="username">{{ row.username || '--' }}</span>
<span class="username">{{ row.user_username || '--' }}</span>
</div>
</template>
</el-table-column>
@@ -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: '取消' }
)

View File

@@ -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

View File

@@ -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
}
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"),
})
}

View File

@@ -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"`
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

View File

@@ -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 {

View File

@@ -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 响应给客户端

View File

@@ -12,6 +12,7 @@ type ConversationMember struct {
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
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)"` // 最后更新时间
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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 模块初始化

View File

@@ -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
}

View File

@@ -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 + 通知在线好友

View File

@@ -187,6 +187,7 @@ CREATE TABLE im_conversation_members (
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)
@@ -200,6 +201,7 @@ 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 '最后更新时间';

View File

@@ -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
```

View File

@@ -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": [
"message": "success",
"data": {
"list": [
{
"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
"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
}
]
}
```
---
## 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",
"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"
}
}
```
---
## 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
}
}
```

View File

@@ -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"
}
]
}
```
---

View File

@@ -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`,后期可分库 |

View File

@@ -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()
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 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,
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,
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (conversation_id, user_id)
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 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 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,
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} → 全局未读消息总数 (STRINGLua 脚本原子递减,下限 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会议与通知 📋 待规划
- 多人音视频会议即时会议 + 预约会议
- 消息通知系统

View File

@@ -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 消息收发时序图
@@ -205,8 +205,9 @@ CREATE TABLE im_conversation_members (
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(),
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` → 更新 typingUsers3 秒自动清除)
- `im.offline.sync` → 初始化未读数 + 刷新会话列表
### 8.3 API 封装api/chat.js

View File

@@ -1,7 +1,7 @@
# EchoChat 项目开发进度
> **最后更新**2026-03-03Phase 2b 即时通讯核心 + ui-ux-pro-max 规范改造完成
> **当前阶段**Phase 2b 已完成
> **最后更新**2026-03-03Phase 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 注入 OnlineCheckerGetFriendList 返回 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 一次获取 peerIDN+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 - 群聊与增强(待规划)
- 群聊会话(建群/加入/退出/管理
- 群消息收发
- 已读回执
- 已读回执(单聊 + 群聊)
- 消息类型扩展(图片/语音/文件)
- 管理端消息管理功能

View File

@@ -1,14 +1,44 @@
<script>
/**
* 应用入口
*
* 全局生命周期:
* - onLaunch: 初始化 WebSocket 连接(如果用户已登录)和事件监听
* - onShow: 检查 WebSocket 连接状态并恢复
*/
import { useUserStore } from '@/store/user'
import { useWebSocketStore } from '@/store/websocket'
import { useChatStore } from '@/store/chat'
import { useContactStore } from '@/store/contact'
export default {
onLaunch: function () {
onLaunch() {
console.log('App Launch')
this._initGlobalWS()
},
onShow: function () {
onShow() {
console.log('App Show')
const userStore = useUserStore()
const wsStore = useWebSocketStore()
if (userStore.isLoggedIn && !wsStore.isConnected) {
wsStore.connect()
}
},
onHide: function () {
onHide() {
console.log('App Hide')
},
methods: {
_initGlobalWS() {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return
const wsStore = useWebSocketStore()
wsStore.connect()
const chatStore = useChatStore()
const contactStore = useContactStore()
chatStore.initWsListeners()
contactStore.initWsListeners()
}
}
}
</script>

View File

@@ -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) {

View File

@@ -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; }

View File

@@ -21,6 +21,9 @@
focus
@confirm="onSearch"
/>
<view v-if="keyword" class="search-clear" @tap="keyword = ''; groups = []; searched = false">
<uni-icons type="clear" size="18" color="#94A3B8" />
</view>
</view>
<view class="search-cancel" @tap="goBack">
<text class="search-cancel-text">取消</text>
@@ -29,28 +32,35 @@
<!-- 搜索结果 -->
<scroll-view scroll-y class="result-list">
<!-- 空状态 -->
<view v-if="searched && results.length === 0" class="empty-state">
<text class="empty-text">未找到相关消息</text>
<view v-if="searching" class="status-state">
<text class="status-text">搜索中...</text>
</view>
<!-- 结果列表 -->
<view v-else-if="searched && groups.length === 0" class="status-state">
<text class="status-text">未找到相关消息</text>
</view>
<!-- 按会话分组展示 -->
<view v-for="group in groups" :key="group.conversation_id" class="result-group">
<view class="group-header" @tap="openConversation(group)">
<view class="group-avatar-wrap">
<image v-if="group.peer_avatar" class="group-avatar" :src="group.peer_avatar" mode="aspectFill" />
<view v-else class="group-avatar group-avatar-placeholder">
<text class="avatar-text">{{ (group.peer_name || '?')[0] }}</text>
</view>
</view>
<text class="group-name">{{ group.peer_name || '未知用户' }}</text>
<text class="group-count">{{ group.messages.length }} 条相关</text>
</view>
<view
v-for="item in results"
:key="item.id"
v-for="msg in group.messages"
:key="msg.id"
class="result-item"
@tap="openChat(item)"
@tap="openConversation(group)"
>
<view class="result-avatar-wrap">
<image v-if="item.sender_avatar" class="result-avatar" :src="item.sender_avatar" mode="aspectFill" />
<view v-else class="result-avatar result-avatar-placeholder">
<text class="avatar-text">{{ (item.sender_nickname || '?')[0] }}</text>
</view>
</view>
<view class="result-info">
<text class="result-name">{{ item.sender_nickname || '未知用户' }}</text>
<text class="result-content">{{ item.content }}</text>
<text class="result-time">{{ item.created_at }}</text>
<text class="result-sender">{{ msg.sender_nickname || '未知' }}</text>
<text class="result-content">{{ msg.content }}</text>
<text class="result-time">{{ msg.created_at }}</text>
</view>
</view>
</scroll-view>
@@ -58,32 +68,66 @@
</template>
<script>
import { ref } from 'vue'
import { ref, watch } from 'vue'
import imApi from '@/api/im'
import { useChatStore } from '@/store/chat'
export default {
name: 'ChatSearch',
setup() {
const chatStore = useChatStore()
const keyword = ref('')
const results = ref([])
const groups = ref([])
const searched = ref(false)
const searching = ref(false)
let debounceTimer = null
watch(keyword, (val) => {
clearTimeout(debounceTimer)
if (!val.trim()) { groups.value = []; searched.value = false; return }
debounceTimer = setTimeout(() => onSearch(), 300)
})
const onSearch = async () => {
const kw = keyword.value.trim()
if (!kw) return
searching.value = true
searched.value = true
try {
const res = await imApi.searchMessages(kw)
results.value = (res.data && res.data.list) || []
const list = (res.data && res.data.list) || []
groups.value = _groupByConversation(list)
} catch {
results.value = []
groups.value = []
} finally {
searching.value = false
}
}
const openChat = (item) => {
/** 将扁平消息列表按 conversation_id 分组,附加会话的 peer 信息 */
const _groupByConversation = (messages) => {
const map = {}
for (const msg of messages) {
const convId = msg.conversation_id
if (!map[convId]) {
const conv = chatStore.conversationList.find(c => c.id === convId)
map[convId] = {
conversation_id: convId,
peer_name: conv?.peer_nickname || msg.sender_nickname || '未知',
peer_avatar: conv?.peer_avatar || msg.sender_avatar || '',
peer_id: conv?.peer_user_id || 0,
messages: []
}
}
map[convId].messages.push(msg)
}
return Object.values(map)
}
const openConversation = (group) => {
uni.navigateTo({
url: `/pages/chat/conversation?conversationId=${item.conversation_id}&peerId=${item.sender_id}&peerName=${encodeURIComponent(item.sender_nickname || '')}&peerAvatar=${encodeURIComponent(item.sender_avatar || '')}`
url: `/pages/chat/conversation?conversationId=${group.conversation_id}&peerId=${group.peer_id}&peerName=${encodeURIComponent(group.peer_name)}&peerAvatar=${encodeURIComponent(group.peer_avatar)}`
})
}
@@ -91,14 +135,7 @@ export default {
uni.navigateBack()
}
return {
keyword,
results,
searched,
onSearch,
openChat,
goBack
}
return { keyword, groups, searched, searching, onSearch, openConversation, goBack }
}
}
</script>
@@ -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; }
</style>

View File

@@ -67,9 +67,13 @@ export default {
const togglePin = async () => {
const newVal = !isPinned.value
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) {
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) {
try {
await chatStore.deleteConversation(conversationId.value)
uni.navigateBack({ delta: 2 })
} catch {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})

View File

@@ -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
}

View File

@@ -37,7 +37,7 @@
<view v-else-if="searchResults.length > 0" class="result-list">
<view
v-for="user in searchResults"
:key="user.user_id"
:key="user.id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
@@ -53,10 +53,10 @@
<view
v-else
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
:class="{ 'add-btn--disabled': addingMap[user.id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
@@ -79,7 +79,7 @@
<view v-else-if="recommendations.length > 0" class="result-list">
<view
v-for="user in recommendations"
:key="user.user_id"
:key="user.id"
class="user-item"
>
<view class="avatar" :style="{ backgroundColor: getAvatarColor(user.nickname || user.username) }">
@@ -91,10 +91,10 @@
</view>
<view
class="add-btn"
:class="{ 'add-btn--disabled': addingMap[user.user_id] }"
:class="{ 'add-btn--disabled': addingMap[user.id] }"
@tap="showAddDialog(user)"
>
<text class="add-btn-text">{{ addingMap[user.user_id] ? '已发送' : '添加' }}</text>
<text class="add-btn-text">{{ addingMap[user.id] ? '已发送' : '添加' }}</text>
</view>
</view>
</view>
@@ -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'
})
}
}
}

View File

@@ -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
})
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)
}
}
/** 更新会话列表中的预览信息 */

View File

@@ -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 = ''