fix: sync authorize monoforum message views

This commit is contained in:
iamxvbaba 2026-07-30 20:09:47 +08:00
parent fd47dd765a
commit 80c36a8ab4
5 changed files with 169 additions and 13 deletions

View file

@ -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{

View file

@ -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{}{}

View file

@ -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"}},

View file

@ -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

View file

@ -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"}},