fix(messages): sync preserve private dialogs after clearing history

This commit is contained in:
iamxvbaba 2026-07-29 13:51:02 +08:00
parent 3926db7762
commit e22fac1c3f
19 changed files with 923 additions and 231 deletions

View file

@ -33,7 +33,7 @@ func (s *MessageStore) DeleteMessages(_ context.Context, req domain.DeleteMessag
if req.Revoke && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
return s.finishMemoryDeleteLocked(res, deleted, req.Date, false), nil
return s.finishMemoryDeleteLocked(res, deleted, req.Date, nil), nil
}
type deletedMemoryMessage struct {
@ -45,8 +45,13 @@ type deletedMemoryMessage struct {
randomID int64
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, preserveEmptyDialogs bool) domain.DeleteMessagesResult {
if len(deleted) == 0 {
type memoryHistoryClearAnchor struct {
message domain.Message
materialized bool
}
func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult, deleted []deletedMemoryMessage, date int, anchors map[int64]memoryHistoryClearAnchor) domain.DeleteMessagesResult {
if len(deleted) == 0 && len(anchors) == 0 {
return res
}
idsByOwner := make(map[int64][]int)
@ -64,57 +69,153 @@ func (s *MessageStore) finishMemoryDeleteLocked(res domain.DeleteMessagesResult,
}
peersByOwner[row.userID][row.peer] = struct{}{}
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for userID, peers := range peersByOwner {
for peer := range peers {
s.rebuildMemoryDialogLocked(userID, peer, preserveEmptyDialogs)
}
for userID, anchor := range anchors {
if peersByOwner[userID] == nil {
peersByOwner[userID] = make(map[domain.Peer]struct{})
}
s.dialogs.mu.Unlock()
peersByOwner[userID][anchor.message.Peer] = struct{}{}
}
ownerIDs := make([]int64, 0, len(idsByOwner))
ownerSet := make(map[int64]struct{}, len(idsByOwner)+len(anchors))
for userID := range idsByOwner {
ownerSet[userID] = struct{}{}
}
for userID, anchor := range anchors {
if !anchor.materialized {
ownerSet[userID] = struct{}{}
}
}
ownerIDs := make([]int64, 0, len(ownerSet))
for userID := range ownerSet {
ownerIDs = append(ownerIDs, userID)
}
sort.Slice(ownerIDs, func(i, j int) bool { return ownerIDs[i] < ownerIDs[j] })
for _, userID := range ownerIDs {
ids := normalizeMemoryMessageIDs(idsByOwner[userID])
if len(ids) == 0 {
anchor, hasAnchor := anchors[userID]
materializeAnchor := hasAnchor && !anchor.materialized
totalPtsCount := len(ids)
if materializeAnchor {
totalPtsCount += 2
}
if totalPtsCount == 0 {
continue
}
pts := s.nextPtsNLocked(userID, len(ids))
event := domain.UpdateEvent{
pts := s.nextPtsNLocked(userID, totalPtsCount)
cursor := pts - totalPtsCount
item := domain.DeletedMessagesForUser{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
MessageIDs: ids,
Pts: pts,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
PtsCount: totalPtsCount,
Events: make([]domain.UpdateEvent, 0, 3),
}
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
if len(ids) > 0 {
cursor += len(ids)
event := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventDeleteMessages,
Pts: cursor,
PtsCount: len(ids),
Date: date,
MessageIDs: ids,
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
for _, row := range deleted {
if row.userID != userID || row.messageSenderID != userID || row.randomID == 0 || row.privateMessageID == 0 {
continue
}
key := privateSendDedupKey{senderUserID: userID, randomID: row.randomID}
record, ok := s.privateSendDedup[key]
if !ok {
continue
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
}
cloned := cloneUpdateEvent(event)
record.senderDeleteEvent = &cloned
s.privateSendDedup[key] = record
item.Event = event
item.Events = append(item.Events, event)
}
res.Deleted = append(res.Deleted, domain.DeletedMessagesForUser{
UserID: userID,
MessageIDs: ids,
Event: event,
})
if materializeAnchor {
readPts := cursor + 1
editPts := readPts + 1
msg := domain.NewHistoryClearMessage(
userID,
anchor.message.Peer,
anchor.message.ID,
anchor.message.UID,
anchor.message.Date,
editPts,
)
for i := range s.m[userID] {
if s.m[userID][i].ID == anchor.message.ID && s.m[userID][i].Peer == anchor.message.Peer {
s.m[userID][i] = msg
break
}
}
if byMessage := s.savedMessageTags[userID]; byMessage != nil {
delete(byMessage, anchor.message.ID)
if len(byMessage) == 0 {
delete(s.savedMessageTags, userID)
}
}
readEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventReadHistoryInbox,
Pts: readPts,
PtsCount: 1,
Date: date,
Peer: anchor.message.Peer,
MaxID: anchor.message.ID,
StillUnreadCount: 0,
}
editEvent := domain.UpdateEvent{
UserID: userID,
Type: domain.UpdateEventEditMessage,
Pts: editPts,
PtsCount: 1,
Date: date,
Message: cloneMessage(msg),
}
item.Events = append(item.Events, readEvent, editEvent)
cursor = editPts
}
if s.dialogs != nil {
s.dialogs.mu.Lock()
for peer := range peersByOwner[userID] {
s.rebuildMemoryDialogLocked(userID, peer)
}
if materializeAnchor {
s.advanceMemoryHistoryClearDialogLocked(userID, anchor.message.Peer, anchor.message.ID)
}
s.dialogs.mu.Unlock()
}
if cursor != pts {
panic(fmt.Sprintf("memory delete history pts cursor %d does not reach reserved pts %d", cursor, pts))
}
res.Deleted = append(res.Deleted, item)
}
return res
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer, preserveEmpty bool) {
func (s *MessageStore) advanceMemoryHistoryClearDialogLocked(userID int64, peer domain.Peer, maxID int) {
list := s.dialogs.m[userID]
for i := range list.Dialogs {
if list.Dialogs[i].Peer != peer {
continue
}
if list.Dialogs[i].ReadInboxMaxID < maxID {
list.Dialogs[i].ReadInboxMaxID = maxID
}
list.Dialogs[i].UnreadCount = 0
list.Dialogs[i].UnreadMark = false
list.Dialogs[i].UnreadMentions = 0
list.Dialogs[i].UnreadReactions = 0
break
}
s.dialogs.m[userID] = list
}
func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer) {
list := s.dialogs.m[userID]
topID := 0
topDate := 0
@ -135,22 +236,6 @@ func (s *MessageStore) rebuildMemoryDialogLocked(userID int64, peer domain.Peer,
continue
}
if topID == 0 {
if preserveEmpty {
oldTop := dialog.TopMessage
dialog.TopMessage = 0
dialog.TopMessageDate = 0
if dialog.ReadInboxMaxID < oldTop {
dialog.ReadInboxMaxID = oldTop
}
if dialog.ReadOutboxMaxID < oldTop {
dialog.ReadOutboxMaxID = oldTop
}
dialog.UnreadCount = 0
dialog.UnreadMark = false
dialog.UnreadMentions = 0
dialog.UnreadReactions = 0
dialogs = append(dialogs, dialog)
}
continue
}
for _, msg := range s.m[userID] {

View file

@ -232,30 +232,66 @@ func (s *MessageStore) DeleteHistory(_ context.Context, req domain.DeleteHistory
}
return true
}
var anchors map[int64]memoryHistoryClearAnchor
fullJustClear := req.JustClear && req.MaxID <= 0 && req.MinDate <= 0 && req.MaxDate <= 0
if fullJustClear {
anchors = make(map[int64]memoryHistoryClearAnchor, 2)
if anchor, found := s.memoryHistoryClearAnchorLocked(req.OwnerUserID, req.Peer); found {
anchors[req.OwnerUserID] = anchor
}
if req.Revoke && req.Peer.ID != req.OwnerUserID {
peer := domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}
if anchor, found := s.memoryHistoryClearAnchorLocked(req.Peer.ID, peer); found {
anchors[req.Peer.ID] = anchor
}
}
}
deleted, revokeUIDs, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.OwnerUserID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == req.Peer && (req.MaxID <= 0 || msg.ID <= req.MaxID) && inDateRange(msg)
})
if req.Revoke {
if len(revokeUIDs) > 0 {
if req.MaxID > 0 && len(revokeUIDs) > 0 {
deleted = append(deleted, s.deleteMemoryMessagesByUIDLocked(revokeUIDs, req.OwnerUserID)...)
}
// 与 PG 同语义:全量/按日期的双向清史直扫对端残余,我方早已
// 单向删除的消息不能在对端残留。
if req.MaxID <= 0 && req.Peer.ID != req.OwnerUserID {
peerDeleted, _, peerMore := s.deleteMemoryMessagesLocked(req.Peer.ID, domain.MaxDeleteHistoryBatch, func(msg domain.Message) bool {
if anchor, ok := anchors[req.Peer.ID]; ok && msg.ID == anchor.message.ID {
return false
}
return msg.Peer == (domain.Peer{Type: domain.PeerTypeUser, ID: req.OwnerUserID}) && inDateRange(msg)
})
deleted = append(deleted, peerDeleted...)
more = more || peerMore
}
}
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, req.JustClear)
res = s.finishMemoryDeleteLocked(res, deleted, req.Date, anchors)
if more {
res.Offset = 1
}
return res, nil
}
func (s *MessageStore) memoryHistoryClearAnchorLocked(userID int64, peer domain.Peer) (memoryHistoryClearAnchor, bool) {
var top domain.Message
for _, msg := range s.m[userID] {
if msg.Peer == peer && msg.ID > top.ID {
top = msg
}
}
if top.ID == 0 {
return memoryHistoryClearAnchor{}, false
}
return memoryHistoryClearAnchor{
message: cloneMessage(top),
materialized: domain.IsHistoryClearServiceMessage(top),
}, true
}
func filterMessageList(messages []domain.Message, filter domain.MessageFilter) domain.MessageList {
filter.AddOffset = domain.ClampMessageHistoryAddOffset(filter.AddOffset)
sort.SliceStable(messages, func(i, j int) bool {

View file

@ -1218,29 +1218,191 @@ func TestMessageStoreDeleteHistoryDeletesOrPreservesDialogAndRebuilds(t *testing
preservedOwner := int64(1000000003)
preservedPeerID := int64(1000000004)
preservedPeer := domain.Peer{Type: domain.PeerTypeUser, ID: preservedPeerID}
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: 300,
Message: "clear but keep dialog",
Date: 1700000500,
}); err != nil {
t.Fatalf("seed preserved send: %v", err)
var preservedTop domain.Message
for i := 0; i < 2; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: preservedOwner,
RecipientUserID: preservedPeerID,
RandomID: int64(300 + i),
Message: "clear but keep dialog",
Date: 1700000500 + i,
})
if err != nil {
t.Fatalf("seed preserved send %d: %v", i, err)
}
preservedTop = sent.SenderMessage
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
clearResult, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000600,
}); err != nil {
})
if err != nil {
t.Fatalf("DeleteHistory just_clear: %v", err)
}
clearSelf := clearResult.Self()
if clearSelf.Pts != 5 || clearSelf.PtsCount != 3 || len(clearSelf.MessageIDs) != 1 || len(clearSelf.Events) != 3 {
t.Fatalf("just_clear result = %+v, want delete+read+edit ending pts=5 count=3", clearSelf)
}
if clearSelf.Events[0].Type != domain.UpdateEventDeleteMessages ||
clearSelf.Events[1].Type != domain.UpdateEventReadHistoryInbox ||
clearSelf.Events[2].Type != domain.UpdateEventEditMessage {
t.Fatalf("just_clear events = %+v, want delete/read/edit order", clearSelf.Events)
}
preservedDialogs, err := dialogs.ListByUser(ctx, preservedOwner, domain.DialogFilter{Limit: 10})
if err != nil {
t.Fatalf("preserved dialogs: %v", err)
}
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer || preservedDialogs.Dialogs[0].TopMessage != 0 || len(preservedDialogs.Messages) != 0 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want empty dialog kept after just_clear", preservedDialogs.Dialogs, preservedDialogs.Messages)
if len(preservedDialogs.Dialogs) != 1 || preservedDialogs.Dialogs[0].Peer != preservedPeer ||
preservedDialogs.Dialogs[0].TopMessage != preservedTop.ID || len(preservedDialogs.Messages) != 1 {
t.Fatalf("preserved dialogs = %+v messages=%+v, want history-clear top %d", preservedDialogs.Dialogs, preservedDialogs.Messages, preservedTop.ID)
}
clearMessage := preservedDialogs.Messages[0]
if !domain.IsHistoryClearServiceMessage(clearMessage) || clearMessage.ID != preservedTop.ID ||
!clearMessage.Out || clearMessage.From.ID != preservedOwner || clearMessage.Body != "" ||
clearMessage.ReplyTo != nil || clearMessage.Forward != nil || clearMessage.MediaUnread ||
clearMessage.ReactionUnread || clearMessage.Pinned {
t.Fatalf("history clear anchor = %+v, want clean owner-local service message", clearMessage)
}
repeated, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: preservedOwner,
Peer: preservedPeer,
JustClear: true,
Date: 1700000601,
})
if err != nil {
t.Fatalf("repeat DeleteHistory just_clear: %v", err)
}
if repeated.Changed() || len(repeated.Deleted) != 0 || messages.nextPts[preservedOwner] != 5 {
t.Fatalf("repeat just_clear = %+v pts=%d, want idempotent no-op", repeated, messages.nextPts[preservedOwner])
}
}
func TestMessageStoreDeleteHistoryJustClearRevokeKeepsPerOwnerAnchors(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const alice, bob = int64(1101), int64(1102)
var sent domain.SendPrivateTextResult
for i := 0; i < 2; i++ {
var err error
sent, err = messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: alice, RecipientUserID: bob, RandomID: int64(800 + i),
Message: "revoke clear", Date: 1700000700 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
res, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: alice,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: bob},
JustClear: true,
Revoke: true,
Date: 1700000800,
})
if err != nil {
t.Fatalf("revoke just_clear: %v", err)
}
if len(res.Deleted) != 2 {
t.Fatalf("deleted owners = %+v, want alice and bob", res.Deleted)
}
for _, tc := range []struct {
userID int64
peerID int64
topID int
}{
{alice, bob, sent.SenderMessage.ID},
{bob, alice, sent.RecipientMessage.ID},
} {
history, err := messages.ListByUser(ctx, tc.userID, domain.MessageFilter{
HasPeer: true, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: tc.peerID}, Limit: 10,
})
if err != nil {
t.Fatalf("history user %d: %v", tc.userID, err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != tc.topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) ||
history.Messages[0].From.ID != tc.userID || !history.Messages[0].Out {
t.Fatalf("history user %d = %+v, want owner-local anchor %d", tc.userID, history.Messages, tc.topID)
}
}
}
func TestMessageStoreDeleteHistoryDateRangeDoesNotCreateHistoryClearAnchor(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1201), int64(1202)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
for i, date := range []int{100, 200} {
if _, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(900 + i),
Message: "dated", Date: date,
}); err != nil {
t.Fatalf("send %d: %v", i, err)
}
}
if _, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, MinDate: 150, MaxDate: 250, Date: 300,
}); err != nil {
t.Fatalf("date delete: %v", err)
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].Date != 100 || domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("date history = %+v, want surviving ordinary message only", history.Messages)
}
}
func TestMessageStoreDeleteHistoryJustClearKeepsAnchorAcrossBatches(t *testing.T) {
ctx := context.Background()
dialogs := NewDialogStore()
messages := NewMessageStore(dialogs)
const owner, peerID = int64(1301), int64(1302)
peer := domain.Peer{Type: domain.PeerTypeUser, ID: peerID}
total := domain.MaxDeleteHistoryBatch + 2
var topID int
for i := 0; i < total; i++ {
sent, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{
SenderUserID: owner, RecipientUserID: peerID, RandomID: int64(10000 + i),
Message: "batch clear", Date: 1700010000 + i,
})
if err != nil {
t.Fatalf("send %d: %v", i, err)
}
topID = sent.SenderMessage.ID
}
first, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020000,
})
if err != nil {
t.Fatalf("first clear: %v", err)
}
if first.Offset == 0 || len(first.Self().MessageIDs) != domain.MaxDeleteHistoryBatch ||
first.Self().PtsCount != domain.MaxDeleteHistoryBatch+2 {
t.Fatalf("first clear = %+v, want full batch plus one read/edit", first.Self())
}
second, err := messages.DeleteHistory(ctx, domain.DeleteHistoryRequest{
OwnerUserID: owner, Peer: peer, JustClear: true, Date: 1700020001,
})
if err != nil {
t.Fatalf("second clear: %v", err)
}
if second.Offset != 0 || len(second.Self().MessageIDs) != 1 || second.Self().PtsCount != 1 ||
len(second.Self().Events) != 1 || second.Self().Events[0].Type != domain.UpdateEventDeleteMessages {
t.Fatalf("second clear = %+v, want remaining delete only", second.Self())
}
history, err := messages.ListByUser(ctx, owner, domain.MessageFilter{HasPeer: true, Peer: peer, Limit: 10})
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history.Messages) != 1 || history.Messages[0].ID != topID ||
!domain.IsHistoryClearServiceMessage(history.Messages[0]) {
t.Fatalf("history = %+v, want stable top anchor %d", history.Messages, topID)
}
}

View file

@ -243,7 +243,7 @@ func (s *MessageStore) DeleteSavedHistory(_ context.Context, req domain.DeleteSa
return true
}
deleted, _, more := s.deleteMemoryMessagesLocked(req.OwnerUserID, domain.MaxDeleteHistoryBatch, match)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, false)
delRes := s.finishMemoryDeleteLocked(domain.DeleteMessagesResult{OwnerUserID: req.OwnerUserID}, deleted, req.Date, nil)
res.More = more
for _, d := range delRes.Deleted {
if d.UserID == req.OwnerUserID {