Add new functionality for managing and synchronizing academic grades and exam schedules. This includes new models, repositories, services, and HTTP handlers, along with updated gRPC definitions and dependency injection configuration. Additionally, improve the reliability of unread message count tracking by implementing an atomic Lua script for Redis operations in the conversation cache.
996 lines
33 KiB
Go
996 lines
33 KiB
Go
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"
|
||
|
||
"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
|
||
GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error)
|
||
GetAllUnreadCount(ctx context.Context, userID string) (int64, 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
|
||
}
|
||
|
||
// NewChatService 创建聊天服务
|
||
func NewChatService(
|
||
repo repository.MessageRepository,
|
||
userRepo repository.UserRepository,
|
||
sensitive SensitiveService,
|
||
publisher ws.MessagePublisher,
|
||
cacheBackend cache.Cache,
|
||
uploadService *UploadService,
|
||
pushSvc PushService,
|
||
) ChatService {
|
||
// 创建适配器
|
||
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
||
msgRepoAdapter := cache.NewMessageRepositoryAdapter(repo)
|
||
|
||
// 创建会话缓存
|
||
conversationCache := cache.NewConversationCache(
|
||
cacheBackend,
|
||
convRepoAdapter,
|
||
msgRepoAdapter,
|
||
cache.DefaultConversationCacheSettings(),
|
||
)
|
||
|
||
return &chatServiceImpl{
|
||
repo: repo,
|
||
userRepo: userRepo,
|
||
sensitive: sensitive,
|
||
wsHub: publisher,
|
||
pushSvc: pushSvc,
|
||
cache: cacheBackend,
|
||
conversationCache: conversationCache,
|
||
uploadService: uploadService,
|
||
}
|
||
}
|
||
|
||
func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload any) {
|
||
if s.wsHub == nil || len(userIDs) == 0 {
|
||
return
|
||
}
|
||
s.wsHub.PublishToUsers(userIDs, event, payload)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
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 {
|
||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||
zap.String("convID", conversationID),
|
||
zap.Error(err),
|
||
)
|
||
} else {
|
||
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 {
|
||
// 先更新未读计数器,再推送 WS 事件,确保事件中的 total_unread 已包含新消息
|
||
if s.conversationCache != nil {
|
||
for _, p := range participants {
|
||
if p.UserID == senderID {
|
||
continue
|
||
}
|
||
if incrErr := s.conversationCache.IncrementUnread(context.Background(), p.UserID, conversationID); incrErr != nil {
|
||
zap.L().Warn("increment unread from redis hash failed",
|
||
zap.String("userID", p.UserID),
|
||
zap.String("convID", conversationID),
|
||
zap.Error(incrErr),
|
||
)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
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 := ""
|
||
if conv.Type == model.ConversationTypeGroup && conv.Group != nil {
|
||
convName = conv.Group.Name
|
||
}
|
||
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) {
|
||
for _, uid := range ids {
|
||
if pushErr := s.pushSvc.PushChatMessage(context.Background(), uid, conversationID, &sender, convType, convName, message); pushErr != nil {
|
||
zap.L().Debug("push chat message failed",
|
||
zap.String("userID", uid),
|
||
zap.String("conversationID", conversationID),
|
||
zap.Error(pushErr),
|
||
)
|
||
}
|
||
}
|
||
}(offlineTargetIDs, sender)
|
||
}
|
||
}
|
||
|
||
return message, nil
|
||
}
|
||
|
||
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)
|
||
// 清除旧式 Hash 未读数缓存
|
||
if clearErr := s.conversationCache.ClearUnread(ctx, userID, conversationID); clearErr != nil {
|
||
zap.L().Warn("clear unread from redis hash failed",
|
||
zap.String("userID", userID),
|
||
zap.String("convID", conversationID),
|
||
zap.Error(clearErr),
|
||
)
|
||
}
|
||
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
|
||
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
|
||
}
|
||
|
||
// GetUnreadCount 获取指定会话的未读消息数(带缓存)
|
||
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)
|
||
}
|
||
|
||
// 优先算术计算:maxSeq - hasReadSeq(OpenIM 风格,O(1) 无 DB)
|
||
if s.conversationCache != nil {
|
||
if count, err := s.conversationCache.ComputeUnreadCount(ctx, conversationID, userID); err == nil {
|
||
return count, nil
|
||
}
|
||
// 降级到 Redis Hash
|
||
if count, err := s.conversationCache.GetUnreadCountFromHash(ctx, userID, conversationID); err == nil {
|
||
return count, nil
|
||
}
|
||
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
|
||
}
|
||
|
||
return s.repo.GetUnreadCount(conversationID, userID)
|
||
}
|
||
|
||
// GetAllUnreadCount 获取所有会话的未读消息总数
|
||
func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) {
|
||
// 优先算术计算:sum(maxSeq - hasReadSeq)(OpenIM 风格)
|
||
if s.conversationCache != nil {
|
||
if convs, _, err := s.GetConversationList(ctx, userID, 1, 200); err == nil && len(convs) > 0 {
|
||
convIDs := make([]string, len(convs))
|
||
maxSeqs := make(map[string]int64, len(convs))
|
||
for i, conv := range convs {
|
||
convIDs[i] = conv.ID
|
||
if maxSeq, err := s.conversationCache.GetConvMaxSeq(ctx, conv.ID); err == nil {
|
||
maxSeqs[conv.ID] = maxSeq
|
||
}
|
||
}
|
||
// 只有全部会话的 msg_seq 都命中时才用算术结果,避免部分缺失导致低估
|
||
if len(maxSeqs) == len(convIDs) {
|
||
if total, err := s.conversationCache.ComputeAllUnreadCount(ctx, userID, convIDs, maxSeqs); err == nil {
|
||
return total, nil
|
||
}
|
||
}
|
||
}
|
||
// 降级到 Redis Hash
|
||
if total, err := s.conversationCache.GetTotalUnread(ctx, userID); 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 {
|
||
zap.L().Warn("redis get next seq failed, falling back to DB",
|
||
zap.String("convID", conversationID),
|
||
zap.Error(err),
|
||
)
|
||
} else {
|
||
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 批量获取用户在多个会话中的未读数
|
||
func (s *chatServiceImpl) GetUnreadCountBatch(ctx context.Context, userID string, convIDs []string) (map[string]int64, error) {
|
||
// 优先算术计算:maxSeq - hasReadSeq(OpenIM 风格)
|
||
if s.conversationCache != nil && len(convIDs) > 0 {
|
||
maxSeqs := make(map[string]int64, len(convIDs))
|
||
for _, convID := range convIDs {
|
||
if maxSeq, err := s.conversationCache.GetConvMaxSeq(ctx, convID); err == nil {
|
||
maxSeqs[convID] = maxSeq
|
||
}
|
||
}
|
||
if 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 {
|
||
continue
|
||
}
|
||
readSeq, hasRead := readSeqs[convID]
|
||
if !hasRead {
|
||
result[convID] = maxSeq
|
||
} else {
|
||
unread := maxSeq - readSeq
|
||
if unread < 0 {
|
||
unread = 0
|
||
}
|
||
result[convID] = unread
|
||
}
|
||
}
|
||
// 补齐缺失的会话(未命中缓存时归零)
|
||
for _, convID := range convIDs {
|
||
if _, exists := result[convID]; !exists {
|
||
result[convID] = 0
|
||
}
|
||
}
|
||
// 只有全部会话都算出来了才回返回,否则降级
|
||
allComputed := true
|
||
for _, convID := range convIDs {
|
||
if _, exists := maxSeqs[convID]; !exists {
|
||
allComputed = false
|
||
break
|
||
}
|
||
}
|
||
if allComputed {
|
||
return result, nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 降级到 DB
|
||
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
|
||
}
|