2026-04-28 14:53:04 +08:00
|
|
|
|
package service
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-03-17 00:47:17 +08:00
|
|
|
|
"slices"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-04-22 16:01:59 +08:00
|
|
|
|
"with_you/internal/cache"
|
|
|
|
|
|
"with_you/internal/dto"
|
|
|
|
|
|
"with_you/internal/model"
|
|
|
|
|
|
"with_you/internal/pkg/ws"
|
|
|
|
|
|
"with_you/internal/repository"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
2026-03-17 00:47:17 +08:00
|
|
|
|
"go.uber.org/zap"
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"gorm.io/gorm"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 撤回消息的时间限制(2分钟)
|
|
|
|
|
|
const RecallMessageTimeout = 2 * time.Minute
|
|
|
|
|
|
|
2026-05-04 13:07:03 +08:00
|
|
|
|
// 消息幂等性 TTL:24 小时内同一 client_msg_id 不会重复发送
|
|
|
|
|
|
const messageIdempotentTTL = 24 * time.Hour
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 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
|
2026-04-28 14:53:04 +08:00
|
|
|
|
SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error
|
2026-04-25 21:22:52 +08:00
|
|
|
|
SetConversationNotificationMuted(ctx context.Context, conversationID string, userID string, notificationMuted bool) error
|
2026-03-09 21:28:58 +08:00
|
|
|
|
|
|
|
|
|
|
// 消息操作
|
2026-05-04 13:07:03 +08:00
|
|
|
|
SendMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string, clientMsgID string) (*model.Message, error)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// 实时事件相关
|
2026-03-09 21:28:58 +08:00
|
|
|
|
SendTyping(ctx context.Context, senderID string, conversationID string)
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// 在线状态
|
2026-03-09 21:28:58 +08:00
|
|
|
|
IsUserOnline(userID string) bool
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// 仅保存消息到数据库,不发送实时推送(供群聊等自行推送的场景使用)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// chatServiceImpl 聊天服务实现
|
|
|
|
|
|
type chatServiceImpl struct {
|
2026-05-04 13:07:03 +08:00
|
|
|
|
repo repository.MessageRepository
|
|
|
|
|
|
userRepo repository.UserRepository
|
|
|
|
|
|
sensitive SensitiveService
|
|
|
|
|
|
wsHub *ws.Hub
|
|
|
|
|
|
pushSvc PushService
|
|
|
|
|
|
cache cache.Cache
|
2026-03-12 08:38:14 +08:00
|
|
|
|
|
|
|
|
|
|
conversationCache *cache.ConversationCache
|
2026-05-04 13:07:03 +08:00
|
|
|
|
uploadService *UploadService
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewChatService 创建聊天服务
|
|
|
|
|
|
func NewChatService(
|
2026-03-26 18:14:16 +08:00
|
|
|
|
repo repository.MessageRepository,
|
|
|
|
|
|
userRepo repository.UserRepository,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
sensitive SensitiveService,
|
2026-03-26 21:17:49 +08:00
|
|
|
|
wsHub *ws.Hub,
|
2026-03-13 09:38:18 +08:00
|
|
|
|
cacheBackend cache.Cache,
|
2026-03-25 03:57:40 +08:00
|
|
|
|
uploadService *UploadService,
|
2026-04-27 23:20:24 +08:00
|
|
|
|
pushSvc PushService,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
) ChatService {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 创建适配器
|
|
|
|
|
|
convRepoAdapter := cache.NewConversationRepositoryAdapter(repo)
|
|
|
|
|
|
msgRepoAdapter := cache.NewMessageRepositoryAdapter(repo)
|
|
|
|
|
|
|
|
|
|
|
|
// 创建会话缓存
|
|
|
|
|
|
conversationCache := cache.NewConversationCache(
|
2026-03-13 09:38:18 +08:00
|
|
|
|
cacheBackend,
|
2026-03-12 08:38:14 +08:00
|
|
|
|
convRepoAdapter,
|
|
|
|
|
|
msgRepoAdapter,
|
|
|
|
|
|
cache.DefaultConversationCacheSettings(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return &chatServiceImpl{
|
2026-03-12 08:38:14 +08:00
|
|
|
|
repo: repo,
|
|
|
|
|
|
userRepo: userRepo,
|
|
|
|
|
|
sensitive: sensitive,
|
2026-03-26 21:17:49 +08:00
|
|
|
|
wsHub: wsHub,
|
2026-04-27 23:20:24 +08:00
|
|
|
|
pushSvc: pushSvc,
|
2026-05-04 13:07:03 +08:00
|
|
|
|
cache: cacheBackend,
|
2026-03-12 08:38:14 +08:00
|
|
|
|
conversationCache: conversationCache,
|
2026-03-25 03:57:40 +08:00
|
|
|
|
uploadService: uploadService,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-30 04:49:35 +08:00
|
|
|
|
func (s *chatServiceImpl) publishToUsers(userIDs []string, event string, payload any) {
|
2026-03-26 21:17:49 +08:00
|
|
|
|
if s.wsHub == nil || len(userIDs) == 0 {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-03-26 21:17:49 +08:00
|
|
|
|
s.wsHub.PublishToUsers(userIDs, event, payload)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// GetOrCreateConversation 获取或创建私聊会话
|
|
|
|
|
|
func (s *chatServiceImpl) GetOrCreateConversation(ctx context.Context, user1ID, user2ID string) (*model.Conversation, error) {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
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
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// GetConversationList 获取用户的会话列表(带缓存)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (s *chatServiceImpl) GetConversationList(ctx context.Context, userID string, page, pageSize int) ([]*model.Conversation, int64, error) {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 优先使用缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
return s.conversationCache.GetConversationList(ctx, userID, page, pageSize)
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.repo.GetConversations(userID, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// GetConversationByID 获取会话详情(带缓存)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (s *chatServiceImpl) GetConversationByID(ctx context.Context, conversationID string, userID string) (*model.Conversation, error) {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participant, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 获取会话信息(优先使用缓存)
|
|
|
|
|
|
var conv *model.Conversation
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
conv, err = s.conversationCache.GetConversation(ctx, conversationID)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
conv, err = s.repo.GetConversation(conversationID)
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ = participant // 可以用于返回已读位置等信息
|
|
|
|
|
|
|
|
|
|
|
|
return conv, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// DeleteConversationForSelf 仅自己删除会话
|
|
|
|
|
|
func (s *chatServiceImpl) DeleteConversationForSelf(ctx context.Context, conversationID string, userID string) error {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participant, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 失效会话列表缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateConversationList(userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SetConversationPinned 设置会话置顶(用户维度)
|
|
|
|
|
|
func (s *chatServiceImpl) SetConversationPinned(ctx context.Context, conversationID string, userID string, isPinned bool) error {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participant, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
|
|
|
|
|
|
// 失效缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateParticipant(conversationID, userID)
|
|
|
|
|
|
s.conversationCache.InvalidateConversationList(userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-25 21:22:52 +08:00
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-04 13:07:03 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
conv, err := s.getConversation(ctx, conversationID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
return nil, errors.New("会话不存在,请重新创建会话")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 拉黑限制:仅拦截"被拉黑方 -> 拉黑人"方向
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if conv.Type == model.ConversationTypePrivate && s.userRepo != nil {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participants, pErr := s.getParticipants(ctx, conversationID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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("对方未关注你前,仅允许发送一条消息")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-25 03:57:40 +08:00
|
|
|
|
_, err = s.getParticipant(ctx, conversationID, senderID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
return nil, errors.New("您不是该会话的参与者")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("failed to get participant: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 03:57:40 +08:00
|
|
|
|
if s.uploadService != nil {
|
|
|
|
|
|
if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 创建消息
|
|
|
|
|
|
message := &model.Message{
|
|
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
|
SenderID: senderID, // 直接使用string类型的UUID
|
|
|
|
|
|
Segments: segments,
|
|
|
|
|
|
ReplyToID: replyToID,
|
|
|
|
|
|
Status: model.MessageStatusNormal,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 使用事务创建消息并更新seq
|
|
|
|
|
|
if err := s.repo.CreateMessageWithSeq(message); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("failed to save message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateMessagePages(conversationID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("async cache message failed",
|
|
|
|
|
|
zap.String("component", "ChatService"),
|
|
|
|
|
|
zap.String("convID", conversationID),
|
|
|
|
|
|
zap.String("msgID", message.ID),
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-12 08:38:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-05-04 13:07:03 +08:00
|
|
|
|
if clientMsgID != "" && s.cache != nil {
|
|
|
|
|
|
s.cache.Set(cache.MessageIdempotentKey(senderID, clientMsgID), message.ID, messageIdempotentTTL)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-26 21:17:49 +08:00
|
|
|
|
// 获取会话中的参与者并发送消息
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participants, err := s.getParticipants(ctx, conversationID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err == nil {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
targetIDs := make([]string, 0, len(participants))
|
|
|
|
|
|
for _, p := range participants {
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if conv.Type == model.ConversationTypePrivate && p.UserID == senderID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
targetIDs = append(targetIDs, p.UserID)
|
|
|
|
|
|
}
|
|
|
|
|
|
detailType := "private"
|
|
|
|
|
|
if conv.Type == model.ConversationTypeGroup {
|
|
|
|
|
|
detailType = "group"
|
|
|
|
|
|
}
|
2026-05-04 13:07:03 +08:00
|
|
|
|
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),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID == senderID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
if totalUnread, uErr := s.repo.GetAllUnreadCount(p.UserID); uErr == nil {
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.publishToUsers([]string{p.UserID}, "conversation_unread", map[string]any{
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
|
"total_unread": totalUnread,
|
|
|
|
|
|
})
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if s.conversationCache != nil {
|
2026-05-04 13:07:03 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-03-12 08:38:14 +08:00
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
s.conversationCache.InvalidateConversationList(p.UserID)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 23:20:24 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-05-04 13:07:03 +08:00
|
|
|
|
allTargetIDs := make([]string, 0, len(participants))
|
2026-04-27 23:20:24 +08:00
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID == senderID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-05-04 13:07:03 +08:00
|
|
|
|
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),
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-04-27 23:20:24 +08:00
|
|
|
|
}
|
2026-05-04 13:07:03 +08:00
|
|
|
|
}(offlineTargetIDs, sender)
|
2026-04-27 23:20:24 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return message, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func containsImageSegment(segments model.MessageSegments) bool {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
return slices.ContainsFunc(segments, func(seg model.MessageSegment) bool {
|
|
|
|
|
|
return seg.Type == string(model.ContentTypeImage) || seg.Type == "image"
|
|
|
|
|
|
})
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// GetMessages 获取消息历史(分页,带缓存)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (s *chatServiceImpl) GetMessages(ctx context.Context, conversationID string, userID string, page, pageSize int) ([]*model.Message, int64, error) {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 优先使用缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
return s.conversationCache.GetMessages(ctx, conversationID, page, pageSize)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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) {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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) {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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 {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 1. 先写入DB(保证数据一致性,DB是唯一数据源)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
err = s.repo.UpdateLastReadSeq(conversationID, userID, seq)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to update last read seq: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 2. DB 写入成功后,失效缓存(Cache-Aside 模式)
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
// 失效参与者缓存,下次读取时会从 DB 加载最新数据
|
|
|
|
|
|
s.conversationCache.InvalidateParticipant(conversationID, userID)
|
|
|
|
|
|
// 失效未读数缓存
|
|
|
|
|
|
s.conversationCache.InvalidateUnreadCount(userID, conversationID)
|
|
|
|
|
|
// 失效会话列表缓存
|
|
|
|
|
|
s.conversationCache.InvalidateConversationList(userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
participants, pErr := s.getParticipants(ctx, conversationID)
|
2026-03-10 12:58:23 +08:00
|
|
|
|
if pErr == nil {
|
|
|
|
|
|
detailType := "private"
|
|
|
|
|
|
groupID := ""
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
detailType = "group"
|
|
|
|
|
|
if conv.GroupID != nil {
|
|
|
|
|
|
groupID = *conv.GroupID
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
targetIDs := make([]string, 0, len(participants))
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
targetIDs = append(targetIDs, p.UserID)
|
|
|
|
|
|
}
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.publishToUsers(targetIDs, "message_read", map[string]any{
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"detail_type": detailType,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"conversation_id": conversationID,
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"group_id": groupID,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
"user_id": userID,
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"seq": seq,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if totalUnread, uErr := s.repo.GetAllUnreadCount(userID); uErr == nil {
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.publishToUsers([]string{userID}, "conversation_unread", map[string]any{
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
|
"total_unread": totalUnread,
|
2026-03-09 21:28:58 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// GetUnreadCount 获取指定会话的未读消息数(带缓存)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
func (s *chatServiceImpl) GetUnreadCount(ctx context.Context, conversationID string, userID string) (int64, error) {
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 优先使用缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
return s.conversationCache.GetUnreadCount(ctx, userID, conversationID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return s.repo.GetUnreadCount(conversationID, userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// GetAllUnreadCount 获取所有会话的未读消息总数
|
|
|
|
|
|
func (s *chatServiceImpl) GetAllUnreadCount(ctx context.Context, userID string) (int64, error) {
|
|
|
|
|
|
return s.repo.GetAllUnreadCount(userID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RecallMessage 撤回消息(2分钟内)
|
|
|
|
|
|
func (s *chatServiceImpl) RecallMessage(ctx context.Context, messageID string, userID string) error {
|
|
|
|
|
|
// 获取消息
|
2026-03-26 18:14:16 +08:00
|
|
|
|
message, err := s.repo.GetMessageByID(messageID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
2026-03-26 18:14:16 +08:00
|
|
|
|
return errors.New("message not found")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证是否是消息发送者
|
|
|
|
|
|
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)")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// 更新消息状态为已撤回,并清空原始消息内容,仅保留撤回占位
|
2026-03-26 18:14:16 +08:00
|
|
|
|
err = s.repo.RecallMessage(messageID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to recall message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 失效消息缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateConversation(message.ConversationID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if participants, pErr := s.getParticipants(ctx, message.ConversationID); pErr == nil {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
detailType := "private"
|
|
|
|
|
|
groupID := ""
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if conv, convErr := s.getConversation(ctx, message.ConversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
detailType = "group"
|
|
|
|
|
|
if conv.GroupID != nil {
|
|
|
|
|
|
groupID = *conv.GroupID
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
targetIDs := make([]string, 0, len(participants))
|
|
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
targetIDs = append(targetIDs, p.UserID)
|
|
|
|
|
|
}
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.publishToUsers(targetIDs, "message_recall", map[string]any{
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"detail_type": detailType,
|
|
|
|
|
|
"conversation_id": message.ConversationID,
|
|
|
|
|
|
"group_id": groupID,
|
|
|
|
|
|
"message_id": messageID,
|
|
|
|
|
|
"sender_id": userID,
|
|
|
|
|
|
})
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// DeleteMessage 删除消息(仅对自己可见)
|
|
|
|
|
|
func (s *chatServiceImpl) DeleteMessage(ctx context.Context, messageID string, userID string) error {
|
|
|
|
|
|
// 获取消息
|
2026-03-26 18:14:16 +08:00
|
|
|
|
message, err := s.repo.GetMessageByID(messageID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
2026-03-26 18:14:16 +08:00
|
|
|
|
return errors.New("message not found")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err = s.getParticipant(ctx, message.ConversationID, userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
2026-03-26 18:14:16 +08:00
|
|
|
|
return errors.New("no permission to delete this message")
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 对于删除消息,我们使用软删除,但需要确保只对当前用户隐藏
|
|
|
|
|
|
// 这里简化处理:只有发送者可以删除自己的消息
|
|
|
|
|
|
if message.SenderIDStr() != userID {
|
|
|
|
|
|
return errors.New("can only delete your own messages")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 更新消息状态为已删除
|
2026-03-26 18:14:16 +08:00
|
|
|
|
err = s.repo.UpdateMessageStatus(message.ID, model.MessageStatusDeleted)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("failed to delete message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 失效消息缓存
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateConversation(message.ConversationID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// SendTyping 发送正在输入状态
|
|
|
|
|
|
func (s *chatServiceImpl) SendTyping(ctx context.Context, senderID string, conversationID string) {
|
2026-03-26 21:17:49 +08:00
|
|
|
|
if s.wsHub == nil {
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getParticipant(ctx, conversationID, senderID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 获取会话中的其他参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
participants, err := s.getParticipants(ctx, conversationID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
detailType := "private"
|
2026-03-12 08:38:14 +08:00
|
|
|
|
if conv, convErr := s.getConversation(ctx, conversationID); convErr == nil && conv.Type == model.ConversationTypeGroup {
|
2026-03-10 12:58:23 +08:00
|
|
|
|
detailType = "group"
|
|
|
|
|
|
}
|
2026-03-09 21:28:58 +08:00
|
|
|
|
for _, p := range participants {
|
|
|
|
|
|
if p.UserID == senderID {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2026-03-26 21:17:49 +08:00
|
|
|
|
if s.wsHub != nil {
|
2026-03-30 04:49:35 +08:00
|
|
|
|
s.wsHub.PublishToUser(p.UserID, "typing", map[string]any{
|
2026-03-10 12:58:23 +08:00
|
|
|
|
"detail_type": detailType,
|
|
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
|
"user_id": senderID,
|
|
|
|
|
|
"is_typing": true,
|
|
|
|
|
|
})
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// IsUserOnline 检查用户是否在线
|
|
|
|
|
|
func (s *chatServiceImpl) IsUserOnline(userID string) bool {
|
2026-03-26 21:17:49 +08:00
|
|
|
|
if s.wsHub != nil {
|
|
|
|
|
|
return s.wsHub.HasClients(userID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
2026-03-10 12:58:23 +08:00
|
|
|
|
return false
|
2026-03-09 21:28:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 12:58:23 +08:00
|
|
|
|
// SaveMessage 仅保存消息到数据库,不发送实时推送
|
2026-03-09 21:28:58 +08:00
|
|
|
|
// 适用于群聊等由调用方自行负责推送的场景
|
|
|
|
|
|
func (s *chatServiceImpl) SaveMessage(ctx context.Context, senderID string, conversationID string, segments model.MessageSegments, replyToID *string) (*model.Message, error) {
|
|
|
|
|
|
// 验证会话是否存在
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err := s.getConversation(ctx, conversationID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
return nil, errors.New("会话不存在,请重新创建会话")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("failed to get conversation: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 验证用户是否是会话参与者
|
2026-03-12 08:38:14 +08:00
|
|
|
|
_, err = s.getParticipant(ctx, conversationID, senderID)
|
2026-03-09 21:28:58 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
|
|
|
|
return nil, errors.New("您不是该会话的参与者")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil, fmt.Errorf("failed to get participant: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 03:57:40 +08:00
|
|
|
|
if s.uploadService != nil {
|
|
|
|
|
|
if err := s.uploadService.ValidateChatMessageImageSegments(segments); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
message := &model.Message{
|
|
|
|
|
|
ConversationID: conversationID,
|
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
|
Segments: segments,
|
|
|
|
|
|
ReplyToID: replyToID,
|
|
|
|
|
|
Status: model.MessageStatusNormal,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := s.repo.CreateMessageWithSeq(message); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("failed to save message: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-12 08:38:14 +08:00
|
|
|
|
// 新消息会改变分页结果,先失效分页缓存,避免读到旧列表
|
|
|
|
|
|
if s.conversationCache != nil {
|
|
|
|
|
|
s.conversationCache.InvalidateMessagePages(conversationID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 异步写入缓存
|
|
|
|
|
|
go func() {
|
|
|
|
|
|
if err := s.cacheMessage(context.Background(), conversationID, message); err != nil {
|
2026-03-17 00:47:17 +08:00
|
|
|
|
zap.L().Warn("async cache message failed",
|
|
|
|
|
|
zap.String("component", "ChatService"),
|
|
|
|
|
|
zap.String("convID", conversationID),
|
|
|
|
|
|
zap.String("msgID", message.ID),
|
|
|
|
|
|
zap.Error(err),
|
|
|
|
|
|
)
|
2026-03-12 08:38:14 +08:00
|
|
|
|
}
|
|
|
|
|
|
}()
|
|
|
|
|
|
|
2026-03-09 21:28:58 +08:00
|
|
|
|
return message, nil
|
|
|
|
|
|
}
|