package memory import ( "context" "fmt" "telesrv/internal/domain" "telesrv/internal/store" "time" ) type privateSendDedupKey struct { senderUserID int64 randomID int64 } type privateSendDedupRecord struct { recipientUserID int64 senderSnapshot []byte recipientMessage domain.Message fingerprint []byte senderDeleteEvent *domain.UpdateEvent } func (s *MessageStore) Create(_ context.Context, msg domain.Message) (domain.Message, error) { s.mu.Lock() defer s.mu.Unlock() msg.ID = s.nextBoxIDLocked(msg.OwnerUserID) msg.UID = s.nextUID s.nextUID++ msg.Entities = append([]domain.MessageEntity(nil), msg.Entities...) s.m[msg.OwnerUserID] = append(s.m[msg.OwnerUserID], msg) if s.dialogs != nil { s.dialogs.mu.Lock() list := s.dialogs.m[msg.OwnerUserID] list.Messages = append(list.Messages, msg) if msg.Peer.Type == domain.PeerTypeUser && !hasUser(list.Users, msg.Peer.ID) { if u, ok := domain.SystemUserByID(msg.Peer.ID); ok { list.Users = append(list.Users, u) } } s.dialogs.m[msg.OwnerUserID] = list s.dialogs.mu.Unlock() } return msg, nil } func (s *MessageStore) SendPrivateText(_ context.Context, req domain.SendPrivateTextRequest) (domain.SendPrivateTextResult, error) { if !req.HasContent() { return domain.SendPrivateTextResult{}, domain.ErrMessageEmpty } fingerprint, err := store.PrivateSendFingerprint(req) if err != nil { return domain.SendPrivateTextResult{}, err } s.mu.Lock() defer s.mu.Unlock() if replay, found, err := s.lookupPrivateSendReplayLocked(domain.PrivateSendReplayRequest{ SenderUserID: req.SenderUserID, RecipientUserID: req.RecipientUserID, RandomID: req.RandomID, IdempotencyFingerprint: fingerprint, }); err != nil || found { return replay, err } if req.Date == 0 { req.Date = int(time.Now().Unix()) } senderReply, recipientReply, err := s.resolveMemoryReplyLocked(req) if err != nil { return domain.SendPrivateTextResult{}, err } uid := s.nextUID s.nextUID++ sender := domain.Message{ ID: s.nextBoxIDLocked(req.SenderUserID), UID: uid, RandomID: req.RandomID, OwnerUserID: req.SenderUserID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, From: domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID}, Date: req.Date, Out: true, Silent: req.Silent, NoForwards: req.NoForwards, Body: req.Message, Entities: append([]domain.MessageEntity(nil), req.Entities...), Media: req.Media, ViaBotID: req.ViaBotID, GroupedID: req.GroupedID, Effect: req.Effect, ReplyMarkup: cloneReplyMarkup(req.ReplyMarkup), RichMessage: cloneRichMessage(req.RichMessage), ReplyTo: cloneMessageReply(senderReply), Forward: cloneMessageForward(req.Forward), Pts: s.nextPtsLocked(req.SenderUserID), // voice/round 在发送者副本上同样保持"未听",由对端内容已读清除。 MediaUnread: req.Media.HasUnreadPayload() && req.SenderUserID != req.RecipientUserID, } if req.SenderUserID == req.RecipientUserID { sender.SavedPeer = domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward) } recipient := domain.Message{} if req.SenderUserID == req.RecipientUserID { recipient = sender } if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { recipient = sender recipient.ID = s.nextBoxIDLocked(req.RecipientUserID) recipient.OwnerUserID = req.RecipientUserID recipient.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} recipient.Out = false recipient.ReplyTo = cloneMessageReply(recipientReply) // recipient = sender 是值拷贝,共享 sender.ReplyMarkup 指针/Data 切片——深拷 // 让双盒各持独立快照(与 postgres 每盒独立 decode 对齐,I3/I2)。 recipient.ReplyMarkup = cloneReplyMarkup(sender.ReplyMarkup) recipient.RichMessage = cloneRichMessage(sender.RichMessage) recipient.Pts = s.nextPtsLocked(req.RecipientUserID) recipient.MediaUnread = req.Media.HasUnreadPayload() } var senderSnapshot []byte if req.RandomID != 0 { senderSnapshot, err = store.EncodePrivateSendSnapshot(sender) if err != nil { return domain.SendPrivateTextResult{}, err } } s.m[req.SenderUserID] = append(s.m[req.SenderUserID], sender) if req.SenderUserID != req.RecipientUserID && !req.RecipientBlocked { s.m[req.RecipientUserID] = append(s.m[req.RecipientUserID], recipient) } if req.RandomID != 0 { s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] = privateSendDedupRecord{ recipientUserID: req.RecipientUserID, senderSnapshot: senderSnapshot, recipientMessage: immutablePrivateSendReceipt(recipient), fingerprint: append([]byte(nil), fingerprint...), } } if s.dialogs != nil { if recipient.ID != 0 { s.upsertMemoryDialogsLocked(sender, recipient) } else { s.upsertMemoryDialogsLocked(sender, sender) } } return domain.SendPrivateTextResult{ SenderMessage: cloneMessage(sender), RecipientMessage: cloneMessage(recipient), SenderEvent: newMessageEvent(sender), RecipientEvent: newMessageEvent(recipient), }, nil } // LookupPrivateSendReplay returns an existing immutable/current replay receipt without running // any send permission, reply resolution or allocation path. func (s *MessageStore) LookupPrivateSendReplay(_ context.Context, req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) { if req.SenderUserID == 0 || req.RecipientUserID == 0 || req.RandomID == 0 { return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory private send replay: invalid scope") } if err := store.ValidateSendFingerprint(req.IdempotencyFingerprint, "private send replay"); err != nil { return domain.SendPrivateTextResult{}, false, err } s.mu.RLock() defer s.mu.RUnlock() return s.lookupPrivateSendReplayLocked(req) } func (s *MessageStore) lookupPrivateSendReplayLocked(req domain.PrivateSendReplayRequest) (domain.SendPrivateTextResult, bool, error) { record, ok := s.privateSendDedup[privateSendDedupKey{senderUserID: req.SenderUserID, randomID: req.RandomID}] if !ok { return domain.SendPrivateTextResult{}, false, nil } if record.recipientUserID != req.RecipientUserID || !store.SameSendFingerprint(record.fingerprint, req.IdempotencyFingerprint) { return domain.SendPrivateTextResult{}, false, domain.ErrMessageRandomIDDuplicate } firstSender, err := store.DecodePrivateSendSnapshot(record.senderSnapshot) if err != nil { return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message snapshot: %w", err) } sender := firstSender visible := false for _, current := range s.m[req.SenderUserID] { if current.UID == firstSender.UID && current.ID == firstSender.ID { sender = cloneMessage(current) sender.RandomID = firstSender.RandomID visible = true break } } if !visible && record.senderDeleteEvent == nil { return domain.SendPrivateTextResult{}, false, fmt.Errorf("memory duplicate private message %d is absent without a durable sender delete receipt", firstSender.UID) } recipient := cloneMessage(record.recipientMessage) var replayDelete *domain.UpdateEvent if record.senderDeleteEvent != nil { cloned := cloneUpdateEvent(*record.senderDeleteEvent) replayDelete = &cloned } return domain.SendPrivateTextResult{ SenderMessage: sender, RecipientMessage: recipient, SenderEvent: newMessageEvent(firstSender), RecipientEvent: newMessageEvent(recipient), Duplicate: true, ReplayDeleteEvent: replayDelete, }, true, nil } // immutablePrivateSendReceipt keeps the recipient allocation facts used by // store-level idempotency tests. The sender response snapshot is stored as a // versioned JSON value above so all nested media/reply graphs are immutable. func immutablePrivateSendReceipt(msg domain.Message) domain.Message { if msg.ID == 0 { return domain.Message{} } return domain.Message{ ID: msg.ID, UID: msg.UID, RandomID: msg.RandomID, OwnerUserID: msg.OwnerUserID, Peer: msg.Peer, From: msg.From, Date: msg.Date, Out: msg.Out, Pts: msg.Pts, } } func (s *MessageStore) resolveMemoryReplyLocked(req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) { if req.ReplyTo == nil { return nil, nil, nil } if err := domain.ValidateMessageReplyBounds(req.ReplyTo); err != nil { return nil, nil, err } if req.ReplyTo.StoryID > 0 { // story 回复(评论):无源消息可查;story 作者就是会话对端,双盒同持。 reply := &domain.MessageReply{ StoryID: req.ReplyTo.StoryID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID}, } return cloneMessageReply(reply), cloneMessageReply(reply), nil } peer := req.ReplyTo.Peer if peer.ID == 0 { peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.RecipientUserID} } if peer.Type != domain.PeerTypeUser || peer.ID != req.RecipientUserID { return nil, nil, domain.ErrReplyMessageIDInvalid } var target domain.Message for _, msg := range s.m[req.SenderUserID] { if msg.Peer == peer && msg.ID == req.ReplyTo.MessageID { target = msg break } } if target.ID == 0 { return nil, nil, domain.ErrReplyMessageIDInvalid } senderReply := cloneMessageReply(req.ReplyTo) senderReply.MessageID = target.ID senderReply.Peer = peer if req.SenderUserID == req.RecipientUserID { return senderReply, cloneMessageReply(senderReply), nil } for _, msg := range s.m[req.RecipientUserID] { if msg.UID == target.UID { recipientReply := cloneMessageReply(senderReply) recipientReply.MessageID = msg.ID recipientReply.Peer = domain.Peer{Type: domain.PeerTypeUser, ID: req.SenderUserID} return senderReply, recipientReply, nil } } return senderReply, nil, nil } func (s *MessageStore) upsertMemoryDialogsLocked(sender, recipient domain.Message) { s.dialogs.mu.Lock() defer s.dialogs.mu.Unlock() list := s.dialogs.m[sender.OwnerUserID] list = upsertMemoryDialog(list, domain.Dialog{Peer: sender.Peer, TopMessage: sender.ID, TopMessageDate: sender.Date}) // 发送方向清手动未读标记(对齐 postgres UpsertOutboxDialog 与 // channel 发送路径:向会话发出消息即视为已知晓内容)。 for i := range list.Dialogs { if list.Dialogs[i].Peer == sender.Peer { list.Dialogs[i].UnreadMark = false break } } list.Messages = append(list.Messages, sender) s.dialogs.m[sender.OwnerUserID] = list if recipient.OwnerUserID != sender.OwnerUserID { peerList := s.dialogs.m[recipient.OwnerUserID] peerList = upsertMemoryDialog(peerList, domain.Dialog{ Peer: recipient.Peer, TopMessage: recipient.ID, TopMessageDate: recipient.Date, UnreadCount: s.privateUnreadCountLocked(recipient.OwnerUserID, recipient.Peer), }) peerList.Messages = append(peerList.Messages, recipient) s.dialogs.m[recipient.OwnerUserID] = peerList } } func (s *MessageStore) privateUnreadCountLocked(ownerUserID int64, peer domain.Peer) int { readMax := 0 if s.dialogs != nil { if list, ok := s.dialogs.m[ownerUserID]; ok { for _, dialog := range list.Dialogs { if dialog.Peer == peer { readMax = dialog.ReadInboxMaxID break } } } } unread := 0 for _, msg := range s.m[ownerUserID] { if msg.Peer == peer && !msg.Out && msg.ID > readMax { unread++ } } return unread }