From 80c36a8ab4907dc8e6b545cedc72c1f60e69d562 Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:09:47 +0800 Subject: [PATCH] fix: sync authorize monoforum message views --- internal/rpc/messages_monoforum_rpc_test.go | 52 ++++++++++++++-- .../store/memory/channel_message_views.go | 5 +- .../memory/channel_monoforum_send_test.go | 37 ++++++++++++ .../store/postgres/channel_message_views.go | 29 ++++++--- ...channel_monoforum_send_integration_test.go | 59 +++++++++++++++++++ 5 files changed, 169 insertions(+), 13 deletions(-) diff --git a/internal/rpc/messages_monoforum_rpc_test.go b/internal/rpc/messages_monoforum_rpc_test.go index 7cf55646..b893699d 100644 --- a/internal/rpc/messages_monoforum_rpc_test.go +++ b/internal/rpc/messages_monoforum_rpc_test.go @@ -468,11 +468,11 @@ func TestMonoforumSendMessageWritePath(t *testing.T) { } } -// TestMonoforumForwardSuggestedPostAndReactionWritePath 回归 DrKLO 的三条真实路径: +// TestMonoforumForwardSuggestedPostAndMessageMetadataPaths 回归 DrKLO 的四条真实路径: // Add Offer 用 forwardMessages+suggested_post 新建建议,Edit Price/Edit Time 再回复原建议; -// get/send reaction 则必须允许无 member 行的订阅者访问自己的 saved_peer,且不能按猜测 id -// 跨到另一订阅者的子会话。 -func TestMonoforumForwardSuggestedPostAndReactionWritePath(t *testing.T) { +// getMessagesViews 与 get/send reaction 必须允许无 member 行的订阅者访问自己的 saved_peer, +// 且不能按猜测 id 跨到另一订阅者的子会话。 +func TestMonoforumForwardSuggestedPostAndMessageMetadataPaths(t *testing.T) { ctx := context.Background() userStore := memory.NewUserStore() owner, err := userStore.Create(ctx, domain.User{AccessHash: 31, Phone: "15550004001", FirstName: "Owner"}) @@ -526,6 +526,50 @@ func TestMonoforumForwardSuggestedPostAndReactionWritePath(t *testing.T) { t.Fatalf("seed other subscriber message: %v", err) } + // DrKLO 会为带 views/replies 的可见消息每 5 秒批量刷新一次。返回向量必须 + // 保持请求位置,同时不能因 synthetic viewer 无 channel_members 行而报 CHANNEL_PRIVATE, + // 也不能让猜测到的其它 saved_peer 消息被读取或递增。 + messageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: monoInput, + ID: []int{original.Message.ID, otherMessage.Message.ID}, + Increment: true, + }) + if err != nil { + t.Fatalf("subscriber getMessagesViews(monoforum): %v", err) + } + if len(messageViews.Views) != 2 || len(messageViews.Chats) == 0 { + t.Fatalf("subscriber getMessagesViews = %+v, want two positional views with channel context", messageViews) + } + if got, ok := messageViews.Views[0].GetViews(); !ok || got != 1 { + t.Fatalf("subscriber own message views = %d/%v, want 1/true", got, ok) + } + if got, ok := messageViews.Views[1].GetViews(); ok || got != 0 { + t.Fatalf("subscriber cross-saved-peer views = %d/%v, want 0/false", got, ok) + } + repeatedMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: monoInput, ID: []int{original.Message.ID}, Increment: true, + }) + if err != nil { + t.Fatalf("subscriber repeated getMessagesViews(monoforum): %v", err) + } + if got, ok := repeatedMessageViews.Views[0].GetViews(); !ok || got != 1 { + t.Fatalf("subscriber repeated message views = %d/%v, want idempotent 1/true", got, ok) + } + adminMessageViews, err := r.onMessagesGetMessagesViews(WithUserID(ctx, owner.ID), &tg.MessagesGetMessagesViewsRequest{ + Peer: monoInput, + ID: []int{original.Message.ID, otherMessage.Message.ID}, + Increment: true, + }) + if err != nil { + t.Fatalf("admin getMessagesViews(monoforum): %v", err) + } + if got, ok := adminMessageViews.Views[0].GetViews(); !ok || got != 2 { + t.Fatalf("admin first saved-peer views = %d/%v, want 2/true", got, ok) + } + if got, ok := adminMessageViews.Views[1].GetViews(); !ok || got != 1 { + t.Fatalf("admin second saved-peer views = %d/%v, want 1/true", got, ok) + } + // Android 打开 reaction 状态时会先发 getMessagesReactions。订阅者没有 channel_members // 行,但自己的 saved_peer 消息必须正常返回,并携带 saved_peer_id 供客户端归组。 reactionState, err := r.onMessagesGetMessagesReactions(WithUserID(ctx, sub.ID), &tg.MessagesGetMessagesReactionsRequest{ diff --git a/internal/store/memory/channel_message_views.go b/internal/store/memory/channel_message_views.go index 3fbaf15d..bbc6f0ae 100644 --- a/internal/store/memory/channel_message_views.go +++ b/internal/store/memory/channel_message_views.go @@ -17,7 +17,7 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan } s.mu.Lock() defer s.mu.Unlock() - channel, member, err := s.channelAndMemberLocked(req.UserID, req.ChannelID) + channel, member, _, err := s.channelForViewerLocked(req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageViewsResult{}, err } @@ -33,7 +33,8 @@ func (s *ChannelStore) GetChannelMessageViews(_ context.Context, req domain.Chan if _, ok := wanted[msg.ID]; !ok { continue } - if msg.Deleted || msg.ID <= member.AvailableMinID { + if msg.Deleted || msg.ID <= member.AvailableMinID || + !channelMessageVisibleToViewerLocked(channel, member, req.UserID, msg) { continue } visible[msg.ID] = struct{}{} diff --git a/internal/store/memory/channel_monoforum_send_test.go b/internal/store/memory/channel_monoforum_send_test.go index 8545fa67..2d95b439 100644 --- a/internal/store/memory/channel_monoforum_send_test.go +++ b/internal/store/memory/channel_monoforum_send_test.go @@ -141,6 +141,43 @@ func TestSendMonoforumMessageAndHistory(t *testing.T) { if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID { t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID) } + ptsBeforeViews := store.channels[monoID].Pts + subViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + Increment: true, Date: 1_700_001_006, + }) + if err != nil { + t.Fatalf("subscriber get monoforum message views: %v", err) + } + if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 { + t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID) + } + if _, ok := subViews.Views[otherMessage.Message.ID]; ok { + t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID) + } + repeatedViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: 42, ChannelID: monoID, IDs: []int{m1.Message.ID}, + Increment: true, Date: 1_700_001_007, + }) + if err != nil || repeatedViews.Views[m1.Message.ID] != 1 { + t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err) + } + if got := store.msgViews[monoID][otherMessage.Message.ID]; got != 0 { + t.Fatalf("hidden saved_peer views = %d, want 0 before admin view", got) + } + adminViews, err := store.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: 1, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + Increment: true, Date: 1_700_001_008, + }) + if err != nil { + t.Fatalf("admin get monoforum message views: %v", err) + } + if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 { + t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views) + } + if got := store.channels[monoID].Pts; got != ptsBeforeViews { + t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", got, ptsBeforeViews) + } if _, err := store.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ UserID: 42, ChannelID: monoID, MessageID: m1.Message.ID, Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}}, diff --git a/internal/store/postgres/channel_message_views.go b/internal/store/postgres/channel_message_views.go index 715fa2cc..5c79f189 100644 --- a/internal/store/postgres/channel_message_views.go +++ b/internal/store/postgres/channel_message_views.go @@ -24,7 +24,7 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch if req.UserID == 0 || req.ChannelID == 0 { return domain.ChannelMessageViewsResult{}, domain.ErrChannelInvalid } - channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID) + channel, member, _, err := s.getChannelForViewer(ctx, s.db, req.UserID, req.ChannelID) if err != nil { return domain.ChannelMessageViewsResult{}, err } @@ -43,6 +43,16 @@ func (s *ChannelStore) GetChannelMessageViews(ctx context.Context, req domain.Ch if date <= 0 { date = nowUnix() } + args := []any{req.ChannelID, id32, req.UserID, date, member.AvailableMinID} + visibility := "" + if channel.Monoforum && !member.CanManageDirectMessages() { + args = append(args, string(domain.PeerTypeUser), req.UserID) + visibility = fmt.Sprintf( + " AND m.saved_peer_type = $%d AND m.saved_peer_id = $%d", + len(args)-1, + len(args), + ) + } rows, err := s.db.Query(ctx, ` WITH inserted AS ( INSERT INTO channel_message_viewers (channel_id, message_id, viewer_user_id, viewed_at) @@ -52,6 +62,7 @@ WITH inserted AS ( AND m.id = ANY($2::int[]) AND NOT m.deleted AND m.id > $5 + `+visibility+` ON CONFLICT DO NOTHING RETURNING message_id ), updated AS ( @@ -65,7 +76,7 @@ WITH inserted AS ( ) SELECT i.message_id FROM inserted i -LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, date, member.AvailableMinID) +LEFT JOIN updated u ON u.id = i.message_id`, args...) if err != nil { return domain.ChannelMessageViewsResult{}, fmt.Errorf("increment channel message views: %w", err) } @@ -82,7 +93,7 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da } rows.Close() } - summaries, err := s.listChannelMessageViewSummaries(ctx, req.ChannelID, id32, member.AvailableMinID) + summaries, err := s.listChannelMessageViewSummaries(ctx, req.UserID, channel, member, id32) if err != nil { return domain.ChannelMessageViewsResult{}, err } @@ -110,13 +121,17 @@ LEFT JOIN updated u ON u.id = i.message_id`, req.ChannelID, id32, req.UserID, da }, nil } -func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, channelID int64, ids []int32, availableMinID int) ([]channelMessageViewSummary, error) { - args := []any{channelID, ids} +func (s *ChannelStore) listChannelMessageViewSummaries(ctx context.Context, viewerUserID int64, channel domain.Channel, member domain.ChannelMember, ids []int32) ([]channelMessageViewSummary, error) { + args := []any{channel.ID, ids} where := "channel_id = $1 AND id = ANY($2::int[]) AND NOT deleted" - if availableMinID > 0 { - args = append(args, availableMinID) + if member.AvailableMinID > 0 { + args = append(args, member.AvailableMinID) where += fmt.Sprintf(" AND id > $%d", len(args)) } + if channel.Monoforum && !member.CanManageDirectMessages() { + args = append(args, string(domain.PeerTypeUser), viewerUserID) + where += fmt.Sprintf(" AND saved_peer_type = $%d AND saved_peer_id = $%d", len(args)-1, len(args)) + } rows, err := s.db.Query(ctx, ` SELECT id, views_count, post, discussion_channel_id, discussion_message_id, sender_user_id, from_peer_type, from_peer_id FROM channel_messages diff --git a/internal/store/postgres/channel_monoforum_send_integration_test.go b/internal/store/postgres/channel_monoforum_send_integration_test.go index bfccacd3..c9c06a41 100644 --- a/internal/store/postgres/channel_monoforum_send_integration_test.go +++ b/internal/store/postgres/channel_monoforum_send_integration_test.go @@ -203,6 +203,65 @@ func TestSendMonoforumMessageAndHistoryPostgres(t *testing.T) { if len(exactMessages.Messages) != 1 || exactMessages.Messages[0].ID != m1.Message.ID { t.Fatalf("subscriber exact monoforum messages = %+v, want only own message %d", exactMessages.Messages, m1.Message.ID) } + monoBeforeViews, err := channels.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum before views: %v", err) + } + subViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + Increment: true, Date: 1700001006, + }) + if err != nil { + t.Fatalf("subscriber get monoforum message views: %v", err) + } + if len(subViews.Views) != 1 || subViews.Views[m1.Message.ID] != 1 { + t.Fatalf("subscriber monoforum views = %+v, want own message %d at 1", subViews.Views, m1.Message.ID) + } + if _, ok := subViews.Views[otherMessage.Message.ID]; ok { + t.Fatalf("subscriber monoforum views leaked other saved_peer message %d", otherMessage.Message.ID) + } + var hiddenViews int + var hiddenViewer bool + if err := pool.QueryRow(ctx, ` +SELECT m.views_count, + EXISTS ( + SELECT 1 + FROM channel_message_viewers v + WHERE v.channel_id = m.channel_id + AND v.message_id = m.id + AND v.viewer_user_id = $3 + ) +FROM channel_messages m +WHERE m.channel_id = $1 AND m.id = $2`, monoID, otherMessage.Message.ID, sub.ID).Scan(&hiddenViews, &hiddenViewer); err != nil { + t.Fatalf("load hidden monoforum view state: %v", err) + } + if hiddenViews != 0 || hiddenViewer { + t.Fatalf("hidden monoforum view state = count %d viewer %v, want 0/false", hiddenViews, hiddenViewer) + } + repeatedViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: sub.ID, ChannelID: monoID, IDs: []int{m1.Message.ID}, + Increment: true, Date: 1700001007, + }) + if err != nil || repeatedViews.Views[m1.Message.ID] != 1 { + t.Fatalf("repeated subscriber monoforum views = %+v, %v; want idempotent 1", repeatedViews.Views, err) + } + adminViews, err := channels.GetChannelMessageViews(ctx, domain.ChannelMessageViewsRequest{ + UserID: owner.ID, ChannelID: monoID, IDs: []int{m1.Message.ID, otherMessage.Message.ID}, + Increment: true, Date: 1700001008, + }) + if err != nil { + t.Fatalf("admin get monoforum message views: %v", err) + } + if len(adminViews.Views) != 2 || adminViews.Views[m1.Message.ID] != 2 || adminViews.Views[otherMessage.Message.ID] != 1 { + t.Fatalf("admin monoforum views = %+v, want both saved peers at 2/1", adminViews.Views) + } + monoAfterViews, err := channels.GetChannelByID(ctx, monoID) + if err != nil { + t.Fatalf("get monoforum after views: %v", err) + } + if monoAfterViews.Pts != monoBeforeViews.Pts { + t.Fatalf("message views advanced monoforum pts = %d, want unchanged %d", monoAfterViews.Pts, monoBeforeViews.Pts) + } if _, err := channels.SetChannelMessageReactions(ctx, domain.SetChannelMessageReactionsRequest{ UserID: sub.ID, ChannelID: monoID, MessageID: m1.Message.ID, Reactions: []domain.MessageReaction{{Type: domain.MessageReactionEmoji, Emoticon: "\U0001f44d"}},