package service import ( "context" "errors" "fmt" "slices" "time" "with_you/internal/cache" "with_you/internal/dto" "with_you/internal/model" "with_you/internal/pkg/ws" "with_you/internal/repository" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" ) // 撤回消息的时间限制(2分钟) const RecallMessageTimeout = 2 * time.Minute // 消息幂等性 TTL:24 小时内同一 client_msg_id 不会重复发送 const messageIdempotentTTL = 24 * time.Hour // ChatService 聊天服务接口 type ChatService interface { // 会话管理 GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) GetConversationList(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error) GetConversationByID(ctx context.Context, conversationID string, userID string) (*model.Conversation, error) DeleteConversationForSelf(ctx context.Context, conversationID string, userID string) error SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error // 消息操作 SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string, clientMsgID string) (*model.Message, error) GetMessages(ctx context.Context, conversationID string, userID string, page, pageSize int) ([]*model.Message, int64, error) GetMessagesAfterSeq(ctx context.Context, conversationID string, userID string, afterSeq int64, limit int) ([]*model.Message, error) GetMessagesBeforeSeq(ctx context.Context, conversationID string, userID string, beforeSeq int64, limit int) ([]*model.Message, error) // 已读管理 MarkAsRead(ctx context.Context, conversationID string, userID string, seq int64) error MarkAsReadBatch(ctx context.Context, userID string, items []dto.BatchMarkReadItem) (int, error) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) // 消息同步 GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error) // 增量同步(版本日志) GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error) // 消息扩展功能 RecallMessage(ctx context.Context, messageID string, userID string) error DeleteMessage(ctx context.Context, messageID string, userID string) error // 实时事件相关 SendTyping(ctx context.Context, senderID string, conversationID string) // 在线状态 IsUserOnline(userID string) bool // 仅保存消息到数据库,不发送实时推送(供群聊等自行推送的场景使用) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) // 批量查询(解决 N+1 问题) GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error) // 已读位置(OpenIM 风格缓存) GetUserReadSeq(ctx context.Context, conversationID string, userID string) (int64, error) } // chatServiceImpl 聊天服务实现 type chatServiceImpl struct { repo repository.MessageRepository userRepo repository.UserRepository sensitive SensitiveService wsHub ws.MessagePublisher pushSvc PushService cache cache.Cache conversationCache *cache.ConversationCache uploadService *UploadService versionLogRepo repository.ConversationVersionLogRepository versionLogEnabled bool versionLogMaxGap int64 pushWorker *PushWorker redisClient *redis.Client } // NewChatService 创建聊天服务 func NewChatService( repo repository.MessageRepository, userRepo repository.UserRepository, sensitive SensitiveService, publisher ws.MessagePublisher, cacheBackend cache.Cache, uploadService *UploadService, pushSvc PushService, seqBufferMgr *cache.SeqBufferManager, pushWorker *PushWorker, redisClient *redis.Client, versionLogRepo ...repository.ConversationVersionLogRepository, ) ChatService { // 创建适配器 convRepoAdapter := cache.NewConversationRepositoryAdapter(repo) msgRepoAdapter := cache.NewMessageRepositoryAdapter(repo) // 创建会话缓存 conversationCache := cache.NewConversationCache( cacheBackend, convRepoAdapter, msgRepoAdapter, cache.DefaultConversationCacheSettings(), seqBufferMgr, ) impl := &chatServiceImpl{ repo: repo, userRepo: userRepo, sensitive: sensitive, wsHub: publisher, pushSvc: pushSvc, cache: cacheBackend, conversationCache: conversationCache, uploadService: uploadService, pushWorker: pushWorker, redisClient: redisClient, } if len(versionLogRepo) > 0 && versionLogRepo[0] != nil { impl.versionLogRepo = versionLogRepo[0] impl.versionLogEnabled = true impl.versionLogMaxGap = 1000 } return impl } func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload any) { if s.wsHub == nil || len(userIDs) == 0 { return } s.wsHub.PublishToUsers(userIDs, event, payload) } // writeVersionLog 写入版本日志(异步,失败仅 warn) func (s *chatServiceImpl) writeVersionLog(ctx context.Context, userID, conversationID, changeType string) { if !s.versionLogEnabled || s.versionLogRepo == nil { return } maxV, err := s.versionLogRepo.GetMaxVersion(ctx, userID) if err != nil { zap.L().Warn("writeVersionLog: get max version failed", zap.String("userID", userID), zap.Error(err)) return } vlog := &model.ConversationVersionLog{ UserID: userID, ConversationID: conversationID, Version: maxV + 1, ChangeType: changeType, } if err := s.versionLogRepo.Create(ctx, vlog); err != nil { zap.L().Warn("writeVersionLog: create failed", zap.String("userID", userID), zap.Error(err)) } } // writeVersionLogBatch 批量写入版本日志(异步,失败仅 warn) func (s *chatServiceImpl) writeVersionLogBatch(ctx context.Context, userIDs []string, conversationID, changeType string) { if !s.versionLogEnabled || s.versionLogRepo == nil { return } logs := make([]*model.ConversationVersionLog, 0, len(userIDs)) for _, uid := range userIDs { maxV, err := s.versionLogRepo.GetMaxVersion(ctx, uid) if err != nil { zap.L().Warn("writeVersionLogBatch: get max version failed", zap.String("userID", uid), zap.Error(err)) continue } logs = append(logs, &model.ConversationVersionLog{ UserID: uid, ConversationID: conversationID, Version: maxV + 1, ChangeType: changeType, }) } if len(logs) > 0 { if err := s.versionLogRepo.BatchCreate(ctx, logs); err != nil { zap.L().Warn("writeVersionLogBatch: batch create failed", zap.Error(err)) } } } // GetSyncByVersion 增量同步:按版本号获取会话变更 func (s *chatServiceImpl) GetSyncByVersion(ctx context.Context, userID string, sinceVersion int64, limit int) (*dto.SyncVersionResponse, error) { if !s.versionLogEnabled || s.versionLogRepo == nil { return nil, errors.New("version log sync is not enabled") } if limit <= 0 { limit = 100 } currentVersion, err := s.versionLogRepo.GetMaxVersion(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get current version: %w", err) } resp := &dto.SyncVersionResponse{ CurrentVersion: currentVersion, } if currentVersion == 0 { resp.FullSync = true return resp, nil } gap := currentVersion - sinceVersion if gap > s.versionLogMaxGap { resp.FullSync = true return resp, nil } vlogs, err := s.versionLogRepo.GetByVersion(ctx, userID, sinceVersion, limit+1) if err != nil { return nil, fmt.Errorf("failed to get version logs: %w", err) } hasMore := len(vlogs) > limit if hasMore { vlogs = vlogs[:limit] } changes := make([]dto.SyncChangeItem, 0, len(vlogs)) for _, vl := range vlogs { item := dto.SyncChangeItem{ ConversationID: vl.ConversationID, ChangeType: vl.ChangeType, Version: vl.Version, } conv, cErr := s.repo.GetConversation(vl.ConversationID) if cErr == nil && conv != nil { item.LastMessageAt = conv.UpdatedAt.Unix() if maxSeq, sErr := s.conversationCache.GetConvMaxSeq(ctx, vl.ConversationID); sErr == nil { item.MaxSeq = maxSeq } } changes = append(changes, item) } resp.Changes = changes resp.HasMore = hasMore return resp, nil } // GetOrCreateConversation 获取或创建私聊会话 func (s *chatServiceImpl) GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) { conv, err := s.repo.GetOrCreatePrivateConversation(user1ID, user2ID) if err != nil { return nil, err } // 失效会话列表缓存 if s.conversationCache != nil { s.conversationCache.InvalidateConversationList(user1ID) s.conversationCache.InvalidateConversationList(user2ID) } return conv, nil } // GetConversationList 获取用户的会话列表(带缓存) func (s *chatServiceImpl) GetConversationList(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error) { // 优先使用缓存 if s.conversationCache != nil { return s.conversationCache.GetConversationList(ctx, userID, page, pageSize) } return s.repo.GetConversations(userID, page, pageSize) } // GetConversationByID 获取会话详情(带缓存) func (s *chatServiceImpl) GetConversationByID(ctx context.Context, conversationID string, userID string) (*model.Conversation, error) { // 验证用户是否是会话参与者 participant, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("conversation not found or no permission") } return nil, fmt.Errorf("failed to get participant: %w", err) } // 获取会话信息(优先使用缓存) var conv *model.Conversation if s.conversationCache != nil { conv, err = s.conversationCache.GetConversation(ctx, conversationID) } else { conv, err = s.repo.GetConversation(conversationID) } if err != nil { return nil, fmt.Errorf("failed to get conversation: %w", err) } _ = participant // 可以用于返回已读位置等信息 return conv, nil } // getParticipant 获取参与者信息(优先使用缓存) func (s *chatServiceImpl) getParticipant(ctx context.Context, conversationID, userID string) (*model.ConversationParticipant, error) { if s.conversationCache != nil { return s.conversationCache.GetParticipant(ctx, conversationID, userID) } return s.repo.GetParticipant(conversationID, userID) } // DeleteConversationForSelf 仅自己删除会话 func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, conversationID string, userID string) error { participant, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("conversation not found or no permission") } return fmt.Errorf("failed to get participant: %w", err) } if participant.ConversationID == "" { return errors.New("conversation not found or no permission") } if err := s.repo.HideConversationForUser(conversationID, userID); err != nil { return fmt.Errorf("failed to hide conversation: %w", err) } // 失效会话列表缓存 if s.conversationCache != nil { s.conversationCache.InvalidateConversationList(userID) } go s.writeVersionLog(context.Background(), userID, conversationID, "hide") return nil } // SetConversationPinned 设置会话置顶(用户维度) func (s *chatServiceImpl) SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error { participant, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("conversation not found or no permission") } return fmt.Errorf("failed to get participant: %w", err) } if participant.ConversationID == "" { return errors.New("conversation not found or no permission") } if err := s.repo.UpdatePinned(conversationID, userID, isPinned); err != nil { return fmt.Errorf("failed to update pinned status: %w", err) } // 失效缓存 if s.conversationCache != nil { s.conversationCache.InvalidateParticipant(conversationID, userID) s.conversationCache.InvalidateConversationList(userID) } return nil } // SetConversationNotificationMuted 设置会话免打扰状态(用户维度) func (s *chatServiceImpl) SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error { participant, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("conversation not found or no permission") } return fmt.Errorf("failed to get participant: %w", err) } if participant.ConversationID == "" { return errors.New("conversation not found or no permission") } if err := s.repo.UpdateNotificationMuted(conversationID, userID, notificationMuted); err != nil { return fmt.Errorf("failed to update notification_muted status: %w", err) } // 失效缓存 if s.conversationCache != nil { s.conversationCache.InvalidateParticipant(conversationID, userID) s.conversationCache.InvalidateConversationList(userID) } return nil } func (s *chatServiceImpl) SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string, clientMsgID string) (*model.Message, error) { if clientMsgID != "" && s.cache != nil { idemKey := cache.MessageIdempotentKey(senderID, clientMsgID) if cachedMsgID, ok := cache.GetTyped[string](s.cache, idemKey); ok && cachedMsgID != "" { if existing, err := s.repo.GetMessageByID(cachedMsgID); err == nil && existing != nil { return existing, nil } } } conv, err := s.getConversation(ctx, conversationID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("会话不存在,请重新创建会话") } return nil, fmt.Errorf("failed to get conversation: %w", err) } // 拉黑限制:仅拦截"被拉黑方 -> 拉黑人"方向 if conv.Type == model.ConversationTypePrivate && s.userRepo != nil { participants, pErr := s.getParticipants(ctx, conversationID) if pErr != nil { return nil, fmt.Errorf("failed to get participants: %w", pErr) } var sentCount *int64 for _, p := range participants { if p.UserID == senderID { continue } blocked, bErr := s.userRepo.IsBlocked(p.UserID, senderID) if bErr != nil { return nil, fmt.Errorf("failed to check block status: %w", bErr) } if blocked { return nil, ErrUserBlocked } // 陌生人限制:对方未回关前,只允许发送一条文本消息,且禁止发送图片 isFollowedBack, fErr := s.userRepo.IsFollowing(p.UserID, senderID) if fErr != nil { return nil, fmt.Errorf("failed to check follow status: %w", fErr) } if !isFollowedBack { if containsImageSegment(segments) { return nil, errors.New("对方未关注你,暂不支持发送图片") } if sentCount == nil { c, cErr := s.repo.CountMessagesBySenderInConversation(conversationID, senderID) if cErr != nil { return nil, fmt.Errorf("failed to count sender messages: %w", cErr) } sentCount = &c } if *sentCount >= 1 { return nil, errors.New("对方未关注你前,仅允许发送一条消息") } } } } // 验证用户是否是会话参与者 _, err = s.getParticipant(ctx, conversationID, senderID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("您不是该会话的参与者") } return nil, fmt.Errorf("failed to get participant: %w", err) } if s.uploadService != nil { if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { return nil, err } } // 创建消息 message := &model.Message{ ConversationID: conversationID, SenderID: senderID, // 直接使用string类型的UUID Segments: segments, ReplyToID: replyToID, Status: model.MessageStatusNormal, } // 从 Redis 获取下一个 seq if s.conversationCache != nil { seq, err := s.conversationCache.GetNextSeq(ctx, conversationID) if err != nil { return nil, fmt.Errorf("failed to get next seq for conversation %s: %w", conversationID, err) } message.Seq = seq } // 使用事务创建消息并更新seq if err := s.repo.CreateMessageWithSeq(message); err != nil { return nil, fmt.Errorf("failed to save message: %w", err) } // 发送者自动已读:将发送者的 lastReadSeq 更新到消息 seq _ = s.repo.UpdateLastReadSeq(conversationID, senderID, message.Seq) if s.conversationCache != nil { _ = s.conversationCache.SetUserReadSeq(context.Background(), conversationID, senderID, message.Seq) } // 新消息会改变分页结果,先失效分页缓存,避免读到旧列表 if s.conversationCache != nil { s.conversationCache.InvalidateMessagePages(conversationID) } go func() { if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { zap.L().Warn("async cache message failed", zap.String("component", "ChatService"), zap.String("convID", conversationID), zap.String("msgID", message.ID), zap.Error(err), ) } }() if clientMsgID != "" && s.cache != nil { s.cache.Set(cache.MessageIdempotentKey(senderID, clientMsgID), message.ID, messageIdempotentTTL) } // 获取会话中的参与者并发送消息 participants, err := s.getParticipants(ctx, conversationID) if err == nil { // 版本日志:为所有参与者写入 new_msg pids := make([]string, 0, len(participants)) for _, p := range participants { pids = append(pids, p.UserID) } go s.writeVersionLogBatch(context.Background(), pids, conversationID, "new_msg") // 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息 // 注意:已移除 IncrementUnread,未读数完全由 maxSeq - readSeq 算术计算 if s.conversationCache != nil { keys := make([]string, 0, len(participants)*2) for _, p := range participants { keys = append(keys, cache.UnreadDetailKey(p.UserID, conversationID), cache.UnreadConversationKey(p.UserID), ) } if len(keys) > 0 { s.cache.DeleteBatch(keys) } for _, p := range participants { s.conversationCache.InvalidateConversationList(p.UserID) } } // PushWorker 异步推送 or inline fallback if s.pushWorker != nil && s.pushWorker.IsEnabled() { streamMsg := &PushStreamMsg{ ConversationID: conversationID, SenderID: senderID, MessageID: message.ID, ConvType: string(conv.Type), Timestamp: time.Now().UnixMilli(), } if pubErr := PublishToPush(context.Background(), s.redisClient, "msg_push", streamMsg); pubErr != nil { zap.L().Warn("push stream publish failed, falling back to inline push", zap.String("conversationID", conversationID), zap.Error(pubErr), ) s.inlinePush(ctx, conv, participants, senderID, conversationID, message) } } else { s.inlinePush(ctx, conv, participants, senderID, conversationID, message) } } return message, nil } // inlinePush 内联推送(原有逻辑,PushWorker 失败时的 fallback) func (s *chatServiceImpl) inlinePush(ctx context.Context, conv *model.Conversation, participants []*model.ConversationParticipant, senderID, conversationID string, message *model.Message) { targetIDs := make([]string, 0, len(participants)) for _, p := range participants { if conv.Type == model.ConversationTypePrivate && p.UserID == senderID { continue } targetIDs = append(targetIDs, p.UserID) } detailType := "private" if conv.Type == model.ConversationTypeGroup { detailType = "group" } if len(targetIDs) > 20 && conv.Type == model.ConversationTypeGroup { s.wsHub.PublishToUsersConcurrent(targetIDs, "chat_message", map[string]any{ "detail_type": detailType, "message": dto.ConvertMessageToResponse(message), }) } else { s.publishToUsers(targetIDs, "chat_message", map[string]any{ "detail_type": detailType, "message": dto.ConvertMessageToResponse(message), }) } for _, p := range participants { if p.UserID == senderID { continue } if totalUnread, uErr := s.GetAllUnreadCount(ctx, p.UserID); uErr == nil { s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{ "conversation_id": conversationID, "total_unread": totalUnread, }) } } if s.pushSvc != nil && len(participants) > 0 { sender := ChatMessageSender{ID: senderID} if senderUser, sErr := s.userRepo.GetByID(senderID); sErr == nil { sender.Name = senderUser.Nickname sender.Avatar = senderUser.Avatar } convType := conv.Type convName := "" groupID := "" groupAvatar := "" if conv.Type == model.ConversationTypeGroup && conv.Group != nil { convName = conv.Group.Name groupID = conv.Group.ID groupAvatar = conv.Group.Avatar } allTargetIDs := make([]string, 0, len(participants)) for _, p := range participants { if p.UserID == senderID { continue } allTargetIDs = append(allTargetIDs, p.UserID) } var offlineTargetIDs []string if s.wsHub != nil && len(allTargetIDs) > 0 { _, offlineTargetIDs = s.wsHub.FilterOnline(allTargetIDs) } else { offlineTargetIDs = allTargetIDs } if len(offlineTargetIDs) > 0 { go func(ids []string, sender ChatMessageSender, gid string, gavatar string) { for _, uid := range ids { if pushErr := s.pushSvc.PushChatMessage(context.Background(), uid, conversationID, &sender, convType, convName, message, gid, gavatar); pushErr != nil { zap.L().Debug("push chat message failed", zap.String("userID", uid), zap.String("conversationID", conversationID), zap.Error(pushErr), ) } } }(offlineTargetIDs, sender, groupID, groupAvatar) } } } func (s *chatServiceImpl) getConversation(ctx context.Context, conversationID string) (*model.Conversation, error) { if s.conversationCache != nil { return s.conversationCache.GetConversation(ctx, conversationID) } return s.repo.GetConversation(conversationID) } // getParticipants 获取会话参与者(优先使用缓存) func (s *chatServiceImpl) getParticipants(ctx context.Context, conversationID string) ([]*model.ConversationParticipant, error) { if s.conversationCache != nil { return s.conversationCache.GetParticipants(ctx, conversationID) } return s.repo.GetConversationParticipants(conversationID) } // cacheMessage 缓存消息(内部方法) func (s *chatServiceImpl) cacheMessage(ctx context.Context, convID string, msg *model.Message) error { if s.conversationCache == nil { return nil } asyncCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() return s.conversationCache.CacheMessage(asyncCtx, convID, msg) } func containsImageSegment(segments model.MessageSegments) bool { return slices.ContainsFunc(segments, func(seg model.MessageSegment) bool { return seg.Type == string(model.ContentTypeImage) || seg.Type == "image" }) } // GetMessages 获取消息历史(分页,带缓存) func (s *chatServiceImpl) GetMessages(ctx context.Context, conversationID string, userID string, page, pageSize int) ([]*model.Message, int64, error) { // 验证用户是否是会话参与者 _, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, 0, errors.New("conversation not found or no permission") } return nil, 0, fmt.Errorf("failed to get participant: %w", err) } // 优先使用缓存 if s.conversationCache != nil { return s.conversationCache.GetMessages(ctx, conversationID, page, pageSize) } return s.repo.GetMessages(conversationID, page, pageSize) } // GetMessagesAfterSeq 获取指定seq之后的消息(用于增量同步) func (s *chatServiceImpl) GetMessagesAfterSeq(ctx context.Context, conversationID string, userID string, afterSeq int64, limit int) ([]*model.Message, error) { // 验证用户是否是会话参与者 _, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("conversation not found or no permission") } return nil, fmt.Errorf("failed to get participant: %w", err) } if limit <= 0 { limit = 100 } return s.repo.GetMessagesAfterSeq(conversationID, afterSeq, limit) } // GetMessagesBeforeSeq 获取指定seq之前的历史消息(用于下拉加载更多) func (s *chatServiceImpl) GetMessagesBeforeSeq(ctx context.Context, conversationID string, userID string, beforeSeq int64, limit int) ([]*model.Message, error) { // 验证用户是否是会话参与者 _, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("conversation not found or no permission") } return nil, fmt.Errorf("failed to get participant: %w", err) } if limit <= 0 { limit = 20 } return s.repo.GetMessagesBeforeSeq(conversationID, beforeSeq, limit) } // MarkAsRead 标记已读 func (s *chatServiceImpl) MarkAsRead(ctx context.Context, conversationID string, userID string, seq int64) error { // 验证用户是否是会话参与者 _, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errors.New("conversation not found or no permission") } return fmt.Errorf("failed to get participant: %w", err) } // 1. 先写入DB(保证数据一致性,DB是唯一数据源) err = s.repo.UpdateLastReadSeq(conversationID, userID, seq) if err != nil { return fmt.Errorf("failed to update last read seq: %w", err) } // 2. 更新 Redis hasReadSeq 缓存(防回退) if s.conversationCache != nil { _ = s.conversationCache.SetUserReadSeq(ctx, conversationID, userID, seq) // 清除旧// 失效参与者缓存 s.conversationCache.InvalidateParticipant(conversationID, userID) // 失效未读数缓存(旧 cache-aside 键) s.conversationCache.InvalidateUnreadCount(userID, conversationID) // 失效会话列表缓存 s.conversationCache.InvalidateConversationList(userID) } participants, pErr := s.getParticipants(ctx, conversationID) if pErr == nil { detailType := "private" groupID := "" conv, _ := s.getConversation(ctx, conversationID) if conv != nil && conv.Type == model.ConversationTypeGroup { detailType = "group" if conv.GroupID != nil { groupID = *conv.GroupID } } // 私聊:通知所有参与者(含对方);群聊:只通知自己 readTargets := []string{userID} if conv != nil && conv.Type == model.ConversationTypePrivate { readTargets = make([]string, 0, len(participants)) for _, p := range participants { readTargets = append(readTargets, p.UserID) } } s.publishToUsers(readTargets, "message_read", map[string]any{ "detail_type": detailType, "conversation_id": conversationID, "group_id": groupID, "user_id": userID, "seq": seq, }) } if totalUnread, uErr := s.GetAllUnreadCount(ctx, userID); uErr == nil { s.publishToUsers([]string{userID}, "conversation_unread", map[string]any{ "conversation_id": conversationID, "total_unread": totalUnread, }) } return nil } // MarkAsReadBatch 批量标记多个会话已读 func (s *chatServiceImpl) MarkAsReadBatch(ctx context.Context, userID string, items []dto.BatchMarkReadItem) (int, error) { successCount := 0 for _, item := range items { _, err := s.getParticipant(ctx, item.ConversationID, userID) if err != nil { continue } if err := s.repo.UpdateLastReadSeq(item.ConversationID, userID, item.LastReadSeq); err != nil { continue } if s.conversationCache != nil { _ = s.conversationCache.SetUserReadSeq(ctx, item.ConversationID, userID, item.LastReadSeq) s.conversationCache.InvalidateParticipant(item.ConversationID, userID) s.conversationCache.InvalidateUnreadCount(userID, item.ConversationID) } successCount++ } if s.conversationCache != nil { s.conversationCache.InvalidateConversationList(userID) } // 为每个成功的会话发送 read 通知 for i := 0; i < successCount && i < len(items); i++ { item := items[i] participants, pErr := s.getParticipants(ctx, item.ConversationID) if pErr == nil { conv, _ := s.getConversation(ctx, item.ConversationID) readTargets := []string{userID} if conv != nil && conv.Type == model.ConversationTypePrivate { readTargets = make([]string, 0, len(participants)) for _, p := range participants { readTargets = append(readTargets, p.UserID) } } s.publishToUsers(readTargets, "message_read", map[string]any{ "conversation_id": item.ConversationID, "user_id": userID, "seq": item.LastReadSeq, }) } } // 发送总未读数通知 if totalUnread, uErr := s.GetAllUnreadCount(ctx, userID); uErr == nil { s.publishToUsers([]string{userID}, "conversation_unread", map[string]any{ "total_unread": totalUnread, }) } return successCount, nil } // GetUnreadCount 获取指定会话的未读消息数(纯算术:maxSeq - readSeq) func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) { _, err := s.getParticipant(ctx, conversationID, userID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return 0, errors.New("conversation not found or no permission") } return 0, fmt.Errorf("failed to get participant: %w", err) } if s.conversationCache != nil { if count, err := s.conversationCache.ComputeUnreadCount(ctx, conversationID, userID); err == nil { return count, nil } } return s.repo.GetUnreadCount(conversationID, userID) } // GetAllUnreadCount 获取所有会话的未读消息总数(批量 MGet maxSeq,纯算术) func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) { if s.conversationCache != nil { if convs, _, err := s.GetConversationList(ctx, userID, 1, 200); err == nil && len(convs) > 0 { convIDs := make([]string, len(convs)) for i, conv := range convs { convIDs[i] = conv.ID } maxSeqs, err := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs) if err == nil && len(maxSeqs) == len(convIDs) { if total, err := s.conversationCache.ComputeAllUnreadCount(ctx, userID, convIDs, maxSeqs); err == nil { return total, nil } } } } return s.repo.GetAllUnreadCount(userID) } // RecallMessage 撤回消息(2分钟内) func (s *chatServiceImpl) RecallMessage(ctx context.Context, messageID string, userID string) error { // 获取消息 message, err := s.repo.GetMessageByID(messageID) if err != nil { return errors.New("message not found") } // 验证是否是消息发送者 if message.SenderIDStr() != userID { return errors.New("can only recall your own messages") } // 验证消息是否已被撤回 if message.Status == model.MessageStatusRecalled { return errors.New("message already recalled") } // 验证是否在2分钟内 if time.Since(message.CreatedAt) > RecallMessageTimeout { return errors.New("message recall timeout (2 minutes)") } // 更新消息状态为已撤回,并清空原始消息内容,仅保留撤回占位 err = s.repo.RecallMessage(messageID, userID) if err != nil { return fmt.Errorf("failed to recall message: %w", err) } // 失效消息缓存 if s.conversationCache != nil { s.conversationCache.InvalidateConversation(message.ConversationID) } if participants, pErr := s.getParticipants(ctx, message.ConversationID); pErr == nil { detailType := "private" groupID := "" if conv, convErr := s.getConversation(ctx, message.ConversationID); convErr == nil && conv.Type == model.ConversationTypeGroup { detailType = "group" if conv.GroupID != nil { groupID = *conv.GroupID } } targetIDs := make([]string, 0, len(participants)) for _, p := range participants { targetIDs = append(targetIDs, p.UserID) } s.publishToUsers(targetIDs, "message_recall", map[string]any{ "detail_type": detailType, "conversation_id": message.ConversationID, "group_id": groupID, "message_id": messageID, "sender_id": userID, }) } return nil } // DeleteMessage 删除消息(仅对自己可见) func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, userID string) error { // 获取消息 message, err := s.repo.GetMessageByID(messageID) if err != nil { return errors.New("message not found") } // 验证用户是否是会话参与者 _, err = s.getParticipant(ctx, message.ConversationID, userID) if err != nil { return errors.New("no permission to delete this message") } // 对于删除消息,我们使用软删除,但需要确保只对当前用户隐藏 // 这里简化处理:只有发送者可以删除自己的消息 if message.SenderIDStr() != userID { return errors.New("can only delete your own messages") } // 更新消息状态为已删除 err = s.repo.UpdateMessageStatus(message.ID, model.MessageStatusDeleted) if err != nil { return fmt.Errorf("failed to delete message: %w", err) } // 失效消息缓存 if s.conversationCache != nil { s.conversationCache.InvalidateConversation(message.ConversationID) } return nil } // SendTyping 发送正在输入状态 func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) { if s.wsHub == nil { return } // 验证用户是否是会话参与者 _, err := s.getParticipant(ctx, conversationID, senderID) if err != nil { return } // 获取会话中的其他参与者 participants, err := s.getParticipants(ctx, conversationID) if err != nil { return } detailType := "private" if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup { detailType = "group" } for _, p := range participants { if p.UserID == senderID { continue } if s.wsHub != nil { s.wsHub.PublishToUser(p.UserID, "typing", map[string]any{ "detail_type": detailType, "conversation_id": conversationID, "user_id": senderID, "is_typing": true, }) } } } // IsUserOnline 检查用户是否在线 func (s *chatServiceImpl) IsUserOnline(userID string) bool { if s.wsHub != nil { return s.wsHub.HasClients(userID) } return false } // SaveMessage 仅保存消息到数据库,不发送实时推送 // 适用于群聊等由调用方自行负责推送的场景 func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) { // 验证会话是否存在 _, err := s.getConversation(ctx, conversationID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("会话不存在,请重新创建会话") } return nil, fmt.Errorf("failed to get conversation: %w", err) } // 验证用户是否是会话参与者 _, err = s.getParticipant(ctx, conversationID, senderID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errors.New("您不是该会话的参与者") } return nil, fmt.Errorf("failed to get participant: %w", err) } if s.uploadService != nil { if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil { return nil, err } } message := &model.Message{ ConversationID: conversationID, SenderID: senderID, Segments: segments, ReplyToID: replyToID, Status: model.MessageStatusNormal, } // 从 Redis 获取下一个 seq(SaveMessage 方法) if s.conversationCache != nil { seq, err := s.conversationCache.GetNextSeq(ctx, conversationID) if err != nil { return nil, fmt.Errorf("failed to get next seq for conversation %s: %w", conversationID, err) } message.Seq = seq } if err := s.repo.CreateMessageWithSeq(message); err != nil { return nil, fmt.Errorf("failed to save message: %w", err) } // 新消息会改变分页结果,先失效分页缓存,避免读到旧列表 if s.conversationCache != nil { s.conversationCache.InvalidateMessagePages(conversationID) } // 异步写入缓存 go func() { if err := s.cacheMessage(context.Background(), conversationID, message); err != nil { zap.L().Warn("async cache message failed", zap.String("component", "ChatService"), zap.String("convID", conversationID), zap.String("msgID", message.ID), zap.Error(err), ) } }() return message, nil } // GetUnreadCountBatch 批量获取用户在多个会话中的未读数(批量 MGet) func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) { if s.conversationCache != nil && len(convIDs) > 0 { maxSeqs, err := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs) if err == nil && len(maxSeqs) > 0 { readSeqs, err := s.conversationCache.GetUserReadSeqs(ctx, userID, convIDs) if err == nil { result := make(map[string]int64, len(convIDs)) for _, convID := range convIDs { maxSeq, ok := maxSeqs[convID] if !ok { result[convID] = 0 continue } readSeq, hasRead := readSeqs[convID] if !hasRead { result[convID] = maxSeq } else { unread := maxSeq - readSeq if unread < 0 { unread = 0 } result[convID] = unread } } if len(maxSeqs) == len(convIDs) { return result, nil } } } } return s.repo.GetUnreadCountBatch(ctx, userID, convIDs) } // GetLastMessagesBatch 批量获取每个会话的最后一条消息 func (s *chatServiceImpl) GetLastMessagesBatch(ctx context.Context, convIDs []string) (map[string]*model.Message, error) { return s.repo.GetLastMessagesBatch(ctx, convIDs) } // GetUserReadSeq 获取用户在某会话的已读位置(优先从 Redis 缓存读取) func (s *chatServiceImpl) GetUserReadSeq(ctx context.Context, conversationID string, userID string) (int64, error) { if s.conversationCache != nil { if seq, err := s.conversationCache.GetUserReadSeq(ctx, conversationID, userID); err == nil { return seq, nil } } // 降级到 DB participant, err := s.getParticipant(ctx, conversationID, userID) if err != nil { return 0, err } return participant.LastReadSeq, nil } // GetSyncData 获取用户所有会话的同步元数据(seq + 时间,轻量级) func (s *chatServiceImpl) GetSyncData(ctx context.Context, userID string) ([]dto.SyncDataItem, error) { convs, _, err := s.GetConversationList(ctx, userID, 1, 200) if err != nil { return nil, fmt.Errorf("failed to get conversation list: %w", err) } if len(convs) == 0 { return nil, nil } convIDs := make([]string, len(convs)) for i, conv := range convs { convIDs[i] = conv.ID } maxSeqs, _ := s.conversationCache.GetConvMaxSeqBatch(ctx, convIDs) items := make([]dto.SyncDataItem, 0, len(convs)) for _, conv := range convs { item := dto.SyncDataItem{ ConversationID: conv.ID, MaxSeq: maxSeqs[conv.ID], LastMessageAt: conv.UpdatedAt.Format(time.RFC3339), } items = append(items, item) } return items, nil }