Files
backend/internal/service/chat_service.go
lan 6bf87fec46
All checks were successful
Build Backend / build (push) Successful in 4m20s
Build Backend / build-docker (push) Successful in 1m7s
feat(chat): implement sequence pre-allocation, versioned sync, and push worker
Introduce several performance and synchronization enhancements:
- Implement `SeqBufferManager` to allow sequence number pre-allocation via Redis Lua scripts, reducing atomic increment overhead.
- Add `PushWorker` to handle asynchronous message pushing using Redis Streams.
- Implement incremental conversation synchronization via `ConversationVersionLog` to allow clients to fetch only recent changes.
- Add support for Gzip compression in WebSocket communications to reduce bandwidth usage.
- Update dependency injection and configuration to support these new components.
2026-05-17 23:38:04 +08:00

1192 lines
39 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// 消息幂等性 TTL24 小时内同一 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 获取下一个 seqSaveMessage 方法)
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
}