diff --git a/internal/store/postgres/channel_dialogs.go b/internal/store/postgres/channel_dialogs.go index d857b8a6..d4dcda87 100644 --- a/internal/store/postgres/channel_dialogs.go +++ b/internal/store/postgres/channel_dialogs.go @@ -902,14 +902,23 @@ func (s *ChannelStore) CountChannelArchiveUnread(ctx context.Context, userID int } var peers, messages int32 // JOIN active member:退群残留的 channel_dialogs 行不计入归档徽章。 + // unread 读时动态派生(H4a):不再消费 channel_dialogs.unread_count 缓存列;归档集合 + // 有界(需显式归档建行),每行动态 COUNT 已被 cap 钳制。 + visibleTopID := "CASE WHEN c.top_message_id > m.available_min_id THEN c.top_message_id ELSE 0 END" + visibleReadInbox := "GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id)" if err := s.db.QueryRow(ctx, ` SELECT - COUNT(*) FILTER (WHERE d.unread_count > 0 OR d.unread_mark)::int, - COALESCE(SUM(d.unread_count), 0)::int -FROM channel_dialogs d -JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' -WHERE d.user_id = $1 - AND COALESCE(d.folder_id, 0) = 1`, userID).Scan(&peers, &messages); err != nil { + COUNT(*) FILTER (WHERE t.unread_count > 0 OR t.unread_mark)::int, + COALESCE(SUM(t.unread_count), 0)::int +FROM ( + SELECT `+channelDialogVisibleUnreadCountSQL(visibleReadInbox, visibleTopID)+` AS unread_count, + COALESCE(d.unread_mark, m.unread_mark) AS unread_mark + FROM channel_dialogs d + JOIN channel_members m ON m.channel_id = d.channel_id AND m.user_id = d.user_id AND m.status = 'active' + JOIN channels c ON c.id = d.channel_id AND NOT c.deleted + WHERE d.user_id = $1 + AND COALESCE(d.folder_id, 0) = 1 +) t`, userID).Scan(&peers, &messages); err != nil { return 0, 0, fmt.Errorf("count channel archive unread: %w", err) } return int(peers), int(messages), nil @@ -1112,61 +1121,6 @@ func cappedChannelUnreadCount(whereBody string) string { )`, whereBody, domain.MaxDialogUnreadCount) } -func refreshChannelDialogsAfterDeleteTx(ctx context.Context, tx pgx.Tx, channel domain.Channel) error { - if channel.ID == 0 || !shouldSynchronouslyUpsertChannelDialogs(channel) { - return nil - } - insertUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = $1 - AND msg.id > GREATEST(active.read_inbox_max_id, active.available_min_id) - AND msg.id <= active.visible_top_id - AND NOT msg.deleted - AND msg.sender_user_id <> active.user_id`) - conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id - AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) - AND msg.id <= EXCLUDED.top_message_id - AND NOT msg.deleted - AND msg.sender_user_id <> channel_dialogs.user_id`) - if _, err := tx.Exec(ctx, fmt.Sprintf(` -WITH active AS ( - SELECT - m.user_id, - m.available_min_id, - GREATEST(COALESCE(d.read_inbox_max_id, 0), m.read_inbox_max_id) AS read_inbox_max_id, - LEAST(GREATEST(c.top_message_id, 0), GREATEST(COALESCE(d.read_outbox_max_id, 0), m.read_outbox_max_id, CASE WHEN c.read_inbox_top1_user_id = m.user_id THEN c.read_inbox_top2 ELSE c.read_inbox_top1 END)) AS read_outbox_max_id, - COALESCE(d.unread_mark, m.unread_mark) AS unread_mark, - CASE WHEN $2 > m.available_min_id THEN $2 ELSE 0 END AS visible_top_id - FROM channel_members m - JOIN channels c ON c.id = m.channel_id - LEFT JOIN channel_dialogs d ON d.user_id = m.user_id AND d.channel_id = m.channel_id - WHERE m.channel_id = $1 - AND m.status = 'active' - AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) -) -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark -) -SELECT - user_id, $1, visible_top_id, - CASE WHEN visible_top_id > 0 THEN COALESCE(top_msg.message_date, $3) ELSE 0 END, - read_inbox_max_id, read_outbox_max_id, - %s, - unread_mark -FROM active -LEFT JOIN channel_messages top_msg ON top_msg.channel_id = $1 AND top_msg.id = active.visible_top_id AND NOT top_msg.deleted -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = EXCLUDED.top_message_id, - top_message_date = EXCLUDED.top_message_date, - read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), - unread_count = %s, - unread_mark = EXCLUDED.unread_mark, - updated_at = now()`, insertUnread, conflictUnread), channel.ID, channel.TopMessageID, channel.Date); err != nil { - return fmt.Errorf("refresh channel dialogs after delete: %w", err) - } - return nil -} - func upsertChannelDialogTx(ctx context.Context, tx pgx.Tx, userID int64, channel domain.Channel, top domain.ChannelMessage, readInboxMaxID, readOutboxMaxID int) error { topDate := top.Date if topDate == 0 { @@ -1199,6 +1153,18 @@ ON CONFLICT (user_id, channel_id) DO UPDATE SET return nil } +// upsertChannelDialogsForMessageTx 在发送事务内处理消息的收件人侧读边界副作用。 +// +// 性能审计 H4a:不再对全员(旧闸门为 megagroup ≤ MaxSynchronousChannelDialogFanout) +// upsert channel_dialogs——发送事务 O(成员) 行写 + 行锁是放开大群的阻断项(P1-u 残余)。 +// 所有频道(broadcast / megagroup 不分大小)统一读时派生:unread 由可见 incoming 消息 +// 重算(channelDialogDynamicUnreadCountSQL,capped),top 由 channels.top_message_id 提供, +// read boundary 真值在 channel_members,dialog 行仅承载 pin/folder/unread_mark/draft 等 +// 用户显式状态并按需惰性建行。 +// +// 保留的唯一逐收件人写:skipDelivery(privacy bot)成员的 read_inbox 边界推进——这些 +// 成员被排除投递,必须同步抬边界避免其未读派生把被隐藏消息计成 unread;集合有界 +// (bot-only 候选)。 func upsertChannelDialogsForMessageTx(ctx context.Context, tx pgx.Tx, channel domain.Channel, top domain.ChannelMessage, selfReadUserID int64, skipDeliveryUserIDs ...[]int64) error { if channel.ID == 0 || top.ID == 0 { return nil @@ -1221,75 +1187,11 @@ WHERE channel_id = $1 return fmt.Errorf("advance skipped channel delivery boundary: %w", err) } } - if !shouldSynchronouslyUpsertChannelDialogs(channel) { - return nil - } - topDate := top.Date - if topDate == 0 { - topDate = channel.Date - } - // 下界用 GREATEST(read_inbox, available_min_id) 与 delete/read 路径一致:当前 - // 全局不变量 read_inbox_max_id >= available_min_id 使其为 no-op,但该不变量未由 - // schema 强制,对齐后即便未来有路径只抬 available_min 不抬 read 也不会 over-count。 - insertUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = $1 - AND msg.id > GREATEST(active.read_inbox_max_id, active.available_min_id) - AND msg.id <= $2 - AND NOT msg.deleted - AND msg.sender_user_id <> active.user_id`) - conflictUnread := cappedChannelUnreadCount(`WHERE msg.channel_id = channel_dialogs.channel_id - AND msg.id > GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id) - AND msg.id <= GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id) - AND NOT msg.deleted - AND msg.sender_user_id <> channel_dialogs.user_id`) - if _, err := tx.Exec(ctx, fmt.Sprintf(` -WITH active AS ( - SELECT - m.user_id, - CASE - WHEN m.user_id = $4 THEN GREATEST(m.read_inbox_max_id, $2) - ELSE m.read_inbox_max_id - END AS read_inbox_max_id, - m.available_min_id, - -- sender 的 outbox 回执水位绝不随自己发消息推进:read_outbox_max_id - -- 语义是"已被其他成员读到的最大 ID",推进只能来自对端 readHistory。 - m.read_outbox_max_id - FROM channel_members m - WHERE m.channel_id = $1 - AND m.status = 'active' - AND NOT COALESCE((m.banned_rights->>'ViewMessages')::boolean, false) - AND $2 > m.available_min_id - AND NOT (m.user_id = ANY($5::bigint[])) -) -INSERT INTO channel_dialogs ( - user_id, channel_id, top_message_id, top_message_date, - read_inbox_max_id, read_outbox_max_id, unread_count, unread_mark -) -SELECT - user_id, $1, $2, $3, - read_inbox_max_id, read_outbox_max_id, - %s, - false -FROM active -ON CONFLICT (user_id, channel_id) DO UPDATE SET - top_message_id = GREATEST(channel_dialogs.top_message_id, EXCLUDED.top_message_id), - top_message_date = GREATEST(channel_dialogs.top_message_date, EXCLUDED.top_message_date), - read_inbox_max_id = GREATEST(channel_dialogs.read_inbox_max_id, EXCLUDED.read_inbox_max_id), - read_outbox_max_id = GREATEST(channel_dialogs.read_outbox_max_id, EXCLUDED.read_outbox_max_id), - unread_count = %s, - unread_mark = CASE WHEN channel_dialogs.user_id = $4 THEN false ELSE channel_dialogs.unread_mark END, - updated_at = now()`, insertUnread, conflictUnread), channel.ID, top.ID, topDate, selfReadUserID, skipIDs); err != nil { - return fmt.Errorf("upsert channel message dialogs: %w", err) - } + // skipDelivery 成员可能已有 channel_dialogs 行(pin/归档等惰性建行),其 read_inbox + // 经 GREATEST(d, m) 读取——member 行已推进即可,dialog 行无需同步。 return nil } -func shouldSynchronouslyUpsertChannelDialogs(channel domain.Channel) bool { - if channel.Broadcast { - return false - } - return channel.ParticipantsCount > 0 && channel.ParticipantsCount <= domain.MaxSynchronousChannelDialogFanout -} - func scanChannelDialogRow(row rowScanner, userID int64) (domain.Channel, domain.Dialog, *domain.Peer, error) { var ch domain.Channel var rights, reactionPolicy string diff --git a/internal/store/postgres/channel_message_delete.go b/internal/store/postgres/channel_message_delete.go index 593e2db9..e1bff144 100644 --- a/internal/store/postgres/channel_message_delete.go +++ b/internal/store/postgres/channel_message_delete.go @@ -411,9 +411,8 @@ RETURNING pinned_message_id`, channel.ID, topID, pts).Scan(&latestPinned); err ! channel.TopMessageID = topID channel.Pts = pts channel.PinnedMessageID = latestPinned - if err := refreshChannelDialogsAfterDeleteTx(ctx, tx, channel); err != nil { - return nil, domain.ChannelUpdateEvent{}, channel, err - } + // 删除后不再对全员刷新 channel_dialogs 缓存行(性能审计 H4a):unread 读时由 + // 可见 incoming 消息动态派生(deleted 自动出列),top 由 channels.top_message_id 提供。 event := domain.ChannelUpdateEvent{ ChannelID: channel.ID, Type: domain.ChannelUpdateDeleteMessages, diff --git a/internal/store/postgres/channel_message_integration_test.go b/internal/store/postgres/channel_message_integration_test.go index e873116f..4112cd14 100644 --- a/internal/store/postgres/channel_message_integration_test.go +++ b/internal/store/postgres/channel_message_integration_test.go @@ -65,28 +65,27 @@ func TestChannelStoreSendMessageFansOutDialogRows(t *testing.T) { t.Fatalf("send channel message: %v", err) } - var friendTop, friendReadInbox, friendUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, friend.ID).Scan(&friendTop, &friendReadInbox, &friendUnread); err != nil { - t.Fatalf("read friend dialog row after send: %v", err) + // H4a:发送事务不再对全员 upsert channel_dialogs——dialog 投影读时派生。 + // friend 视角:top 来自 channels.top_message_id,unread 由可见 incoming 消息动态重算。 + friendView, err := channels.GetChannelDialogs(ctx, friend.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get friend dialogs after send: %v", err) } - if friendTop != sent.Message.ID || friendReadInbox != 0 || friendUnread != 2 { - t.Fatalf("friend dialog row top=%d read=%d unread=%d, want top %d read 0 unread 2", friendTop, friendReadInbox, friendUnread, sent.Message.ID) + if len(friendView.Dialogs) != 1 || friendView.Dialogs[0].TopMessage != sent.Message.ID || + friendView.Dialogs[0].ReadInboxMaxID != 0 || friendView.Dialogs[0].UnreadCount != 2 { + t.Fatalf("friend derived dialog = %+v, want top %d read 0 unread 2", friendView.Dialogs, sent.Message.ID) } - var ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, read_outbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID).Scan(&ownerTop, &ownerReadInbox, &ownerReadOutbox, &ownerUnread); err != nil { - t.Fatalf("read owner dialog row after send: %v", err) - } // 发送者自己的 outbox 回执水位不得随发送推进:同账号另一设备会把它当成 // "对端已读"渲染双勾。推进只能来自 peer 的 readHistory(见下方断言)。 - if ownerTop != sent.Message.ID || ownerReadInbox != sent.Message.ID || ownerReadOutbox != 0 || ownerUnread != 0 { - t.Fatalf("owner dialog row top=%d read_in=%d read_out=%d unread=%d, want top/read_in %d, read_out 0 before peer read, unread 0", ownerTop, ownerReadInbox, ownerReadOutbox, ownerUnread, sent.Message.ID) + ownerViewBefore, err := channels.GetChannelDialogs(ctx, owner.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get owner dialogs after send: %v", err) + } + if len(ownerViewBefore.Dialogs) != 1 || ownerViewBefore.Dialogs[0].TopMessage != sent.Message.ID || + ownerViewBefore.Dialogs[0].ReadInboxMaxID != sent.Message.ID || + ownerViewBefore.Dialogs[0].ReadOutboxMaxID != 0 || ownerViewBefore.Dialogs[0].UnreadCount != 0 { + t.Fatalf("owner derived dialog = %+v, want top/read_in %d, read_out 0 before peer read, unread 0", ownerViewBefore.Dialogs, sent.Message.ID) } var ownerMemberReadInbox, ownerMemberReadOutbox int @@ -581,15 +580,15 @@ func TestChannelStoreSendMessageSkipDeliveryAdvancesReadBoundary(t *testing.T) { t.Fatalf("hidden recipients = %+v, want bot skipped", hidden.Recipients) } - var botDialogTop, botReadInbox, botUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { - t.Fatalf("read bot dialog after hidden: %v", err) + // H4a:skip 成员的读边界推进只写 channel_members 行;unread 派生自动把被隐藏 + // 消息排除(read boundary 已越过它)。 + hiddenView, err := channels.GetChannelDialogs(ctx, bot.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get bot dialogs after hidden: %v", err) } - if botDialogTop != created.Message.ID { - t.Fatalf("bot dialog after hidden top=%d, want unchanged create top %d", botDialogTop, created.Message.ID) + if len(hiddenView.Dialogs) != 1 || hiddenView.Dialogs[0].UnreadCount != 0 || + hiddenView.Dialogs[0].ReadInboxMaxID != hidden.Message.ID { + t.Fatalf("bot derived dialog after hidden = %+v, want unread 0 read %d", hiddenView.Dialogs, hidden.Message.ID) } var memberReadInbox int if err := pool.QueryRow(ctx, ` @@ -615,15 +614,14 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&memberReadInbo if !containsInt64ForPostgresTest(visible.Recipients, bot.ID) { t.Fatalf("visible recipients = %+v, want bot", visible.Recipients) } - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, bot.ID).Scan(&botDialogTop, &botReadInbox, &botUnread); err != nil { - t.Fatalf("read bot dialog after visible: %v", err) + visibleView, err := channels.GetChannelDialogs(ctx, bot.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get bot dialogs after visible: %v", err) } - if botDialogTop != visible.Message.ID || botReadInbox != hidden.Message.ID || botUnread != 1 { - t.Fatalf("bot dialog after visible top=%d read=%d unread=%d, want top %d read hidden %d unread visible-only", - botDialogTop, botReadInbox, botUnread, visible.Message.ID, hidden.Message.ID) + if len(visibleView.Dialogs) != 1 || visibleView.Dialogs[0].TopMessage != visible.Message.ID || + visibleView.Dialogs[0].ReadInboxMaxID != hidden.Message.ID || visibleView.Dialogs[0].UnreadCount != 1 { + t.Fatalf("bot derived dialog after visible = %+v, want top %d read hidden %d unread visible-only", + visibleView.Dialogs, visible.Message.ID, hidden.Message.ID) } } diff --git a/internal/store/postgres/channel_read.go b/internal/store/postgres/channel_read.go index 6502d35a..3923aa0d 100644 --- a/internal/store/postgres/channel_read.go +++ b/internal/store/postgres/channel_read.go @@ -40,22 +40,16 @@ func channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr string) string )`, readInboxExpr, topIDExpr) } +// channelDialogVisibleUnreadCountSQL 恒走读时动态派生(性能审计 H4a):broadcast/大群 +// 自 P0-1 起即读时派生并经生产验证,≤1000 megagroup 也统一到同一路径后,发送事务不再 +// 对全员 upsert channel_dialogs,`channel_dialogs.unread_count` 缓存列不再被任何读路径 +// 消费(真值 = read boundary + 可见 incoming 消息重算)。 func channelDialogVisibleUnreadCountSQL(readInboxExpr, topIDExpr string) string { - dynamicCount := channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) - // LEAST 钳制缓存分支 d.unread_count(小群走缓存列,可能因逐条自增长过上界)到 P1-v 上界; - // 动态分支已被 LIMIT 子查询钳过,外层 LEAST 对两分支统一封顶,保证下发角标 ≤ 上界。 - return fmt.Sprintf(`LEAST(CASE - WHEN c.broadcast OR c.participants_count > %d THEN %s - ELSE COALESCE(d.unread_count, %s) - END, %d)`, domain.MaxSynchronousChannelDialogFanout, dynamicCount, dynamicCount, domain.MaxDialogUnreadCount) + return channelDialogDynamicUnreadCountSQL(readInboxExpr, topIDExpr) } func channelDialogHasUnreadSQL(readInboxExpr, topIDExpr string) string { - dynamicUnread := channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) - return fmt.Sprintf(`CASE - WHEN c.broadcast OR c.participants_count > %d THEN %s - ELSE COALESCE(d.unread_count > 0, %s) - END`, domain.MaxSynchronousChannelDialogFanout, dynamicUnread, dynamicUnread) + return channelDialogDynamicUnreadExistsSQL(readInboxExpr, topIDExpr) } func (s *ChannelStore) SetChannelDialogUnreadMark(ctx context.Context, userID, channelID int64, unread bool) (bool, error) { diff --git a/internal/store/postgres/channel_read_integration_test.go b/internal/store/postgres/channel_read_integration_test.go index 0b7c8b72..ea49ac96 100644 --- a/internal/store/postgres/channel_read_integration_test.go +++ b/internal/store/postgres/channel_read_integration_test.go @@ -79,15 +79,14 @@ func TestChannelStoreReadOutboxDoesNotRegressSenderDialogUnread(t *testing.T) { t.Fatalf("send member message: %v", err) } - var storedReadInbox, storedUnread int - if err := pool.QueryRow(ctx, ` -SELECT read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { - t.Fatalf("read member dialog after self send: %v", err) + // H4a:发送不再写全员 dialog 行;sender 自己的 read_inbox 在发送事务内推进 + // channel_members 行,读侧 GREATEST(dialog, member) 派生。 + memberView, err := channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get member dialogs after self send: %v", err) } - if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { - t.Fatalf("member dialog after self send read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + if len(memberView.Dialogs) != 1 || memberView.Dialogs[0].ReadInboxMaxID != memberMsg.Message.ID || memberView.Dialogs[0].UnreadCount != 0 { + t.Fatalf("member derived dialog after self send = %+v, want read %d unread 0", memberView.Dialogs, memberMsg.Message.ID) } read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{ @@ -102,14 +101,12 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadI if len(read.OutboxUpdates) != 1 || read.OutboxUpdates[0].UserID != member.ID || read.OutboxUpdates[0].MaxID != memberMsg.Message.ID { t.Fatalf("read outbox updates = %+v, want member max id %d", read.OutboxUpdates, memberMsg.Message.ID) } - if err := pool.QueryRow(ctx, ` -SELECT read_inbox_max_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&storedReadInbox, &storedUnread); err != nil { - t.Fatalf("read member dialog after owner read: %v", err) + memberView, err = channels.GetChannelDialogs(ctx, member.ID, []int64{channelID}) + if err != nil { + t.Fatalf("get member dialogs after owner read: %v", err) } - if storedReadInbox != memberMsg.Message.ID || storedUnread != 0 { - t.Fatalf("member dialog after owner read read=%d unread=%d, want read %d unread 0", storedReadInbox, storedUnread, memberMsg.Message.ID) + if len(memberView.Dialogs) != 1 || memberView.Dialogs[0].ReadInboxMaxID != memberMsg.Message.ID || memberView.Dialogs[0].UnreadCount != 0 { + t.Fatalf("member derived dialog after owner read = %+v, want read %d unread 0", memberView.Dialogs, memberMsg.Message.ID) } } @@ -680,7 +677,7 @@ WHERE id = $1`, channelID, domain.MaxSynchronousChannelDialogFanout+1); err != n } } -func TestChannelStoreSmallMegagroupDeleteRefreshesDialogUnreadCache(t *testing.T) { +func TestChannelStoreSmallMegagroupDeleteDerivesUnread(t *testing.T) { pool := testPool(t) ctx := context.Background() suffix := randomSuffix(t) @@ -761,15 +758,13 @@ func TestChannelStoreSmallMegagroupDeleteRefreshesDialogUnreadCache(t *testing.T t.Fatalf("send third message: %v", err) } - var cachedTop, cachedUnread int - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { - t.Fatalf("read cached dialog before delete: %v", err) + // H4a:dialog unread 读时派生,不再依赖 channel_dialogs 缓存列刷新。 + beforeDelete, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) + if err != nil { + t.Fatalf("list dialogs before delete: %v", err) } - if cachedTop != third.Message.ID || cachedUnread != 3 { - t.Fatalf("cached dialog before delete top=%d unread=%d, want top %d unread 3", cachedTop, cachedUnread, third.Message.ID) + if len(beforeDelete.Dialogs) != 1 || beforeDelete.Dialogs[0].TopMessage != third.Message.ID || beforeDelete.Dialogs[0].UnreadCount != 3 { + t.Fatalf("derived dialog before delete = %+v, want top %d unread 3", beforeDelete.Dialogs, third.Message.ID) } if _, err := channels.DeleteChannelMessages(ctx, domain.DeleteChannelMessagesRequest{ @@ -780,15 +775,6 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, }); err != nil { t.Fatalf("delete middle unread message: %v", err) } - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { - t.Fatalf("read cached dialog after middle delete: %v", err) - } - if cachedTop != third.Message.ID || cachedUnread != 2 { - t.Fatalf("cached dialog after middle delete top=%d unread=%d, want top %d unread 2", cachedTop, cachedUnread, third.Message.ID) - } list, err := channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list dialogs after middle delete: %v", err) @@ -805,15 +791,6 @@ WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, }); err != nil { t.Fatalf("delete top unread message: %v", err) } - if err := pool.QueryRow(ctx, ` -SELECT top_message_id, unread_count -FROM channel_dialogs -WHERE channel_id = $1 AND user_id = $2`, channelID, member.ID).Scan(&cachedTop, &cachedUnread); err != nil { - t.Fatalf("read cached dialog after top delete: %v", err) - } - if cachedTop != first.Message.ID || cachedUnread != 1 { - t.Fatalf("cached dialog after top delete top=%d unread=%d, want top %d unread 1", cachedTop, cachedUnread, first.Message.ID) - } list, err = channels.ListChannelDialogs(ctx, member.ID, domain.DialogFilter{Limit: 10}) if err != nil { t.Fatalf("list dialogs after top delete: %v", err)