feat:单聊页面bug修复+后台管理端好友页面bug修复+项目进度、记忆文件更新
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 响应给客户端
|
||||
|
||||
@@ -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)"` // 最后更新时间
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 模块初始化
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 + 通知在线好友
|
||||
|
||||
Reference in New Issue
Block a user