fix: sync linked discussion guest support

This commit is contained in:
A 2026-07-11 23:18:24 +08:00
parent 5f7c0b9804
commit 9f73dc20da
27 changed files with 1004 additions and 30 deletions

View file

@ -252,6 +252,38 @@ func (s *ChannelStore) GetChannel(ctx context.Context, viewerUserID, channelID i
return domain.ChannelView{Channel: channel, Self: member, Dialog: dialog, SelfBoostsApplied: selfBoosts, ExportedInvite: exportedInvite}, nil
}
// GetLinkedDiscussionChannel projects a private discussion peer through the
// viewer's active membership in the source broadcast channel. This is a
// peer-discovery boundary only; it never creates discussion-group membership.
func (s *ChannelStore) GetLinkedDiscussionChannel(ctx context.Context, viewerUserID, sourceChannelID int64) (domain.ChannelView, error) {
source, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, sourceChannelID)
if err != nil {
return domain.ChannelView{}, err
}
if !source.Broadcast || source.LinkedChatID == 0 {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
linked, err := s.channelByID(ctx, s.db, source.LinkedChatID)
if err != nil {
return domain.ChannelView{}, err
}
if !linked.Megagroup || linked.Broadcast {
return domain.ChannelView{}, domain.ErrChannelInvalid
}
self, guest, guestErr := s.getLinkedDiscussionGuest(ctx, s.db, viewerUserID, linked)
if guestErr != nil {
return domain.ChannelView{}, guestErr
}
if !guest {
if member, memberErr := s.getChannelMember(ctx, s.db, linked.ID, viewerUserID); memberErr == nil {
self = member
} else {
return domain.ChannelView{}, memberErr
}
}
return domain.ChannelView{Channel: linked, Self: self}, nil
}
// ResolveChannel 是 GetChannel 的轻量版:只做访问校验并返回 Channel(含 access_hash)+Self
// 跳过 dialog top message / 读态 / boost 求和这 3 条额外 PG 查询。供 inputPeerFor 等只需
// access_hash / 频道标志的纯解析路径用——它们此前为拿一个 access_hash 付了完整 4 查询投影。

View file

@ -0,0 +1,138 @@
package postgres
import (
"context"
"testing"
"telesrv/internal/domain"
)
// TestResolveDiscussionReadTargetPostgres locks the one-query readDiscussion
// projection against the real schema. It verifies linked-root mapping and the
// durable idempotent boundary without invoking the full discussion aggregate.
func TestResolveDiscussionReadTargetPostgres(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 991,
Phone: "+1991" + suffix + "01",
FirstName: "DiscussionReadOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
subscriber, err := users.Create(ctx, domain.User{
AccessHash: 992,
Phone: "+1992" + suffix + "02",
FirstName: "DiscussionGuest",
})
if err != nil {
t.Fatalf("create subscriber: %v", err)
}
channels := NewChannelStore(pool,
WithChannelRowCache(NewChannelRowCache(32)),
WithChannelMemberCache(NewChannelMemberCache(64)))
var channelIDs []int64
t.Cleanup(func() {
if len(channelIDs) > 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", channelIDs)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, subscriber.ID})
})
broadcast, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Discussion Read Source " + suffix,
Broadcast: true,
Date: 1700002900,
})
if err != nil {
t.Fatalf("create broadcast: %v", err)
}
channelIDs = append(channelIDs, broadcast.Channel.ID)
group, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "Discussion Read Group " + suffix,
Megagroup: true,
Date: 1700002901,
})
if err != nil {
t.Fatalf("create group: %v", err)
}
channelIDs = append(channelIDs, group.Channel.ID)
if _, err := channels.SetDiscussionGroup(ctx, owner.ID, broadcast.Channel.ID, group.Channel.ID); err != nil {
t.Fatalf("set discussion group: %v", err)
}
if _, err := channels.InviteToChannel(ctx, broadcast.Channel.ID, owner.ID, []int64{subscriber.ID}, 1700002902); err != nil {
t.Fatalf("invite broadcast subscriber: %v", err)
}
post, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: owner.ID,
ChannelID: broadcast.Channel.ID,
RandomID: 9912901,
Message: "discussion read target",
Date: 1700002902,
})
if err != nil {
t.Fatalf("send post: %v", err)
}
if post.Discussion == nil {
t.Fatal("send post discussion result is nil")
}
rootID := post.Discussion.Message.ID
guestView, err := channels.GetChannel(ctx, subscriber.ID, group.Channel.ID)
if err != nil || !guestView.Self.Guest || guestView.Self.Status != domain.ChannelMemberLeft {
t.Fatalf("linked guest view = %+v err %v", guestView.Self, err)
}
guestTarget, err := channels.ResolveDiscussionReadTarget(ctx, subscriber.ID, broadcast.Channel.ID, post.Message.ID, rootID)
if err != nil || !guestTarget.Guest {
t.Fatalf("linked guest read target = %+v err %v", guestTarget, err)
}
directGuestTarget, err := channels.ResolveDiscussionReadTarget(ctx, subscriber.ID, group.Channel.ID, rootID, rootID)
if err != nil || !directGuestTarget.Guest || directGuestTarget.ChannelID != group.Channel.ID {
t.Fatalf("direct linked-group guest read target = %+v err %v", directGuestTarget, err)
}
if _, err := channels.ListActiveChannelBotMemberIDs(ctx, subscriber.ID, group.Channel.ID, 20); err != nil {
t.Fatalf("linked guest bot-delivery preflight: %v", err)
}
if _, err := channels.ListActiveChannelBotMembers(ctx, subscriber.ID, group.Channel.ID, 0, 20); err != nil {
t.Fatalf("linked guest bot participants: %v", err)
}
if _, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
UserID: subscriber.ID, ChannelID: group.Channel.ID, RandomID: 9912902,
Message: "guest comment", Date: 1700002903,
ReplyTo: &domain.MessageReply{MessageID: rootID, TopMessageID: rootID},
}); err != nil {
t.Fatalf("send linked guest comment: %v", err)
}
var persistedGuest bool
if err := pool.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM channel_members WHERE channel_id = $1 AND user_id = $2
)`, group.Channel.ID, subscriber.ID).Scan(&persistedGuest); err != nil {
t.Fatalf("check guest member row: %v", err)
}
if persistedGuest {
t.Fatal("linked guest send persisted a discussion-group member row")
}
target, err := channels.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.Message.ID, rootID)
if err != nil {
t.Fatalf("resolve before read: %v", err)
}
if target.ChannelID != group.Channel.ID || target.RootID != rootID || target.AlreadyRead {
t.Fatalf("target before read = %+v, want linked unread root", target)
}
read, err := channels.ReadChannelHistory(ctx, domain.ReadChannelHistoryRequest{
UserID: owner.ID,
ChannelID: group.Channel.ID,
MaxID: rootID,
Date: 1700002903,
})
if err != nil || !read.Changed {
t.Fatalf("read linked group = %+v err %v, want changed", read, err)
}
target, err = channels.ResolveDiscussionReadTarget(ctx, owner.ID, broadcast.Channel.ID, post.Message.ID, rootID)
if err != nil || !target.AlreadyRead {
t.Fatalf("resolve after read = %+v err %v, want already read", target, err)
}
}

View file

@ -371,6 +371,11 @@ func (s *ChannelStore) getChannelForViewer(ctx context.Context, db sqlcgen.DBTX,
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, false, err
}
if guest, ok, guestErr := s.getLinkedDiscussionGuest(ctx, db, viewerUserID, ch); guestErr != nil {
return domain.Channel{}, domain.ChannelMember{}, false, guestErr
} else if ok {
return ch, guest, true, nil
}
if member, _, ok, err := s.monoforumAdminPreview(ctx, db, viewerUserID, ch); err != nil {
return domain.Channel{}, domain.ChannelMember{}, false, err
} else if ok {

View file

@ -25,6 +25,77 @@ func (s *ChannelStore) getChannelForMember(ctx context.Context, db sqlcgen.DBTX,
return ch, member, nil
}
func (s *ChannelStore) getChannelForMemberOrLinkedGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID, channelID int64) (domain.Channel, domain.ChannelMember, error) {
channel, member, err := s.getChannelForMember(ctx, db, viewerUserID, channelID)
if !errors.Is(err, domain.ErrChannelPrivate) {
return channel, member, err
}
target, targetErr := s.channelByID(ctx, db, channelID)
if targetErr != nil {
return domain.Channel{}, domain.ChannelMember{}, targetErr
}
guest, allowed, guestErr := s.getLinkedDiscussionGuest(ctx, db, viewerUserID, target)
if guestErr != nil {
return domain.Channel{}, domain.ChannelMember{}, guestErr
}
if !allowed {
return domain.Channel{}, domain.ChannelMember{}, err
}
return target, guest, nil
}
// getLinkedDiscussionGuest authorizes a private discussion group through an
// active membership in its bidirectionally linked broadcast. The returned
// member is computed only and must never be persisted. An explicit target ban
// or kick takes precedence over the source-channel membership.
func (s *ChannelStore) getLinkedDiscussionGuest(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, target domain.Channel) (domain.ChannelMember, bool, error) {
if target.Broadcast || !target.Megagroup || target.LinkedChatID == 0 {
return domain.ChannelMember{}, false, nil
}
existing, err := s.getChannelMember(ctx, db, target.ID, viewerUserID)
if err == nil {
if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages {
return domain.ChannelMember{}, false, domain.ErrChannelUserBanned
}
if existing.Status == domain.ChannelMemberActive {
return existing, false, nil
}
} else if !errors.Is(err, domain.ErrChannelPrivate) {
return domain.ChannelMember{}, false, err
}
source, err := s.channelByID(ctx, db, target.LinkedChatID)
if err != nil {
if errors.Is(err, domain.ErrChannelInvalid) {
return domain.ChannelMember{}, false, nil
}
return domain.ChannelMember{}, false, err
}
if !source.Broadcast || source.LinkedChatID != target.ID {
return domain.ChannelMember{}, false, nil
}
sourceMember, err := s.getChannelMember(ctx, db, source.ID, viewerUserID)
if err != nil {
if errors.Is(err, domain.ErrChannelPrivate) {
return domain.ChannelMember{}, false, nil
}
return domain.ChannelMember{}, false, err
}
if err := validateChannelMemberVisible(sourceMember); err != nil {
return domain.ChannelMember{}, false, err
}
guest := domain.ChannelMember{
ChannelID: target.ID,
UserID: viewerUserID,
Status: domain.ChannelMemberLeft,
Role: domain.ChannelRoleMember,
Guest: true,
}
if s.memberCacheActive(db) {
s.memberCache.put(guest)
}
return guest, true, nil
}
func (s *ChannelStore) getPublicPreviewMember(ctx context.Context, db sqlcgen.DBTX, viewerUserID int64, ch domain.Channel) (domain.ChannelMember, error) {
member, err := s.getChannelMember(ctx, db, ch.ID, viewerUserID)
if err != nil {

View file

@ -12,7 +12,7 @@ import (
)
func (s *ChannelStore) GetParticipants(ctx context.Context, viewerUserID, channelID int64, filter domain.ChannelParticipantsFilter, offset, limit int) (domain.ChannelParticipantList, error) {
channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelParticipantList{}, err
}
@ -170,9 +170,13 @@ WHERE channel_id = $1
}
func (s *ChannelStore) GetParticipant(ctx context.Context, viewerUserID, channelID, participantUserID int64) (domain.ChannelMember, error) {
if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil {
_, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelMember{}, err
}
if viewerUserID == participantUserID && viewer.Guest {
return viewer, nil
}
return s.getChannelMember(ctx, s.db, channelID, participantUserID)
}
@ -200,7 +204,7 @@ func (s *ChannelStore) ListActiveChannelMemberIDs(ctx context.Context, viewerUse
}
func (s *ChannelStore) ListActiveChannelMembers(ctx context.Context, viewerUserID, channelID int64, limit int) (domain.Channel, domain.ChannelMember, []domain.ChannelMember, error) {
channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.Channel{}, domain.ChannelMember{}, nil, err
}
@ -233,7 +237,7 @@ LIMIT $2`, channelID, limit)
}
func (s *ChannelStore) ListActiveChannelBotMembers(ctx context.Context, viewerUserID, channelID int64, offset, limit int) (domain.ChannelParticipantList, error) {
channel, viewer, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
channel, viewer, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID)
if err != nil {
return domain.ChannelParticipantList{}, err
}
@ -279,7 +283,7 @@ OFFSET $2 LIMIT $3`, channelID, offset, limit)
}
func (s *ChannelStore) ListActiveChannelBotMemberIDs(ctx context.Context, viewerUserID, channelID int64, limit int) ([]int64, error) {
if _, _, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID); err != nil {
if _, _, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, channelID); err != nil {
return nil, err
}
if limit <= 0 || limit > domain.MaxSynchronousChannelDialogFanout {

View file

@ -565,6 +565,165 @@ func (s *ChannelStore) GetDiscussionMessage(ctx context.Context, viewerUserID, c
return result, nil
}
// ResolveDiscussionReadTarget resolves source post -> linked discussion root
// and checks the durable read boundary in one indexed query. It is the hot path
// for messages.readDiscussion and must not load reply stats, reactions or peer
// payloads used by messages.getDiscussionMessage.
func (s *ChannelStore) ResolveDiscussionReadTarget(ctx context.Context, userID, sourceChannelID int64, sourceMessageID, readMaxID int) (domain.ChannelDiscussionReadTarget, error) {
var out domain.ChannelDiscussionReadTarget
var readInboxMaxID int
var unreadMark bool
var targetVisible bool
var targetGuest bool
var targetTopMessageID int
err := s.db.QueryRow(ctx, `
WITH source AS (
SELECT c.broadcast,
m.discussion_channel_id,
m.discussion_message_id,
(
c.megagroup
AND NOT c.broadcast
AND c.linked_chat_id <> 0
AND (viewer.user_id IS NULL OR viewer.status = 'left')
AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false)
AND EXISTS (
SELECT 1
FROM channels parent
JOIN channel_members parent_member
ON parent_member.channel_id = parent.id
AND parent_member.user_id = $1
AND parent_member.status = 'active'
AND NOT COALESCE((parent_member.banned_rights->>'ViewMessages')::boolean, false)
WHERE parent.id = c.linked_chat_id
AND parent.broadcast
AND NOT parent.deleted
AND parent.linked_chat_id = c.id
)
) AS linked_guest
FROM channels c
LEFT JOIN channel_members viewer
ON viewer.channel_id = c.id
AND viewer.user_id = $1
JOIN channel_messages m
ON m.channel_id = c.id
AND m.id = $3
AND NOT m.deleted
AND m.id > COALESCE(viewer.available_min_id, 0)
WHERE c.id = $2 AND NOT c.deleted
AND (
(
viewer.status = 'active'
AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false)
)
OR (
c.megagroup
AND NOT c.broadcast
AND c.linked_chat_id <> 0
AND (viewer.user_id IS NULL OR viewer.status = 'left')
AND NOT COALESCE((viewer.banned_rights->>'ViewMessages')::boolean, false)
AND EXISTS (
SELECT 1
FROM channels parent
JOIN channel_members parent_member
ON parent_member.channel_id = parent.id
AND parent_member.user_id = $1
AND parent_member.status = 'active'
AND NOT COALESCE((parent_member.banned_rights->>'ViewMessages')::boolean, false)
WHERE parent.id = c.linked_chat_id
AND parent.broadcast
AND NOT parent.deleted
AND parent.linked_chat_id = c.id
)
)
)
), target AS (
SELECT CASE
WHEN broadcast AND discussion_channel_id <> 0 AND discussion_message_id <> 0
THEN discussion_channel_id
ELSE $2
END AS channel_id,
CASE
WHEN broadcast AND discussion_channel_id <> 0 AND discussion_message_id <> 0
THEN discussion_message_id
ELSE $3
END AS root_id,
broadcast,
linked_guest,
discussion_channel_id,
discussion_message_id
FROM source
)
SELECT target.channel_id, target.root_id,
target_channel.top_message_id,
COALESCE(target_member.read_inbox_max_id, 0),
COALESCE(target_member.unread_mark, false),
COALESCE(
target_member.status = 'active'
AND NOT COALESCE((target_member.banned_rights->>'ViewMessages')::boolean, false),
false
) OR target.linked_guest OR (
target.channel_id <> $2
AND target.broadcast
AND target_channel.linked_chat_id = $2
AND (
target_member.user_id IS NULL
OR target_member.status = 'left'
)
) AS target_visible,
(
target.linked_guest OR (
target.channel_id <> $2
AND target.broadcast
AND target_channel.linked_chat_id = $2
AND (
target_member.user_id IS NULL
OR target_member.status = 'left'
)
)
) AS target_guest
FROM target
JOIN channels target_channel
ON target_channel.id = target.channel_id
AND NOT target_channel.deleted
LEFT JOIN channel_members target_member
ON target_member.channel_id = target.channel_id
AND target_member.user_id = $1
WHERE (
NOT target.broadcast
OR (target.discussion_channel_id = 0 AND target.discussion_message_id = 0)
OR (
target.discussion_channel_id <> 0
AND target.discussion_message_id <> 0
AND target_channel.megagroup
AND NOT target_channel.broadcast
AND EXISTS (
SELECT 1
FROM channel_messages root
WHERE root.channel_id = target.channel_id
AND root.id = target.root_id
AND NOT root.deleted
)
)
)`, userID, sourceChannelID, sourceMessageID).Scan(&out.ChannelID, &out.RootID, &targetTopMessageID, &readInboxMaxID, &unreadMark, &targetVisible, &targetGuest)
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelDiscussionReadTarget{}, domain.ErrMessageIDInvalid
}
if err != nil {
return domain.ChannelDiscussionReadTarget{}, fmt.Errorf("resolve discussion read target: %w", err)
}
if !targetVisible {
return domain.ChannelDiscussionReadTarget{}, domain.ErrChannelPrivate
}
effectiveMaxID := readMaxID
if effectiveMaxID <= 0 || effectiveMaxID > targetTopMessageID {
effectiveMaxID = targetTopMessageID
}
out.AlreadyRead = effectiveMaxID <= readInboxMaxID && !unreadMark
out.Guest = targetGuest
return out, nil
}
func (s *ChannelStore) readChannelHistoryOnce(ctx context.Context, req domain.ReadChannelHistoryRequest) (domain.ReadChannelHistoryResult, error) {
channel, _, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {

View file

@ -64,9 +64,23 @@ func (s *ChannelStore) sendChannelMessageOnce(ctx context.Context, req domain.Se
}
}()
channel, member, err := s.getChannelForMember(ctx, tx, req.UserID, req.ChannelID)
if errors.Is(err, domain.ErrChannelPrivate) {
if candidate, candidateErr := s.channelByID(ctx, tx, req.ChannelID); candidateErr == nil {
var guest bool
member, guest, err = s.getLinkedDiscussionGuest(ctx, tx, req.UserID, candidate)
if guest {
channel = candidate
}
} else {
err = candidateErr
}
}
if err != nil {
return domain.SendChannelMessageResult{}, err
}
if member.Guest && channel.JoinToSend {
return domain.SendChannelMessageResult{}, domain.ErrChannelWriteForbidden
}
fromBoostsApplied := 0
if channel.Megagroup {
fromBoostsApplied, err = countActiveUserBoostsForPeer(ctx, tx, req.UserID, domain.Peer{Type: domain.PeerTypeChannel, ID: req.ChannelID}, req.Date)

View file

@ -108,13 +108,16 @@ func (s *ChannelStore) ReadChannelTopicHistory(ctx context.Context, req domain.R
if req.UserID == 0 || req.ChannelID == 0 || req.TopicID <= 0 {
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, req.UserID, req.ChannelID)
channel, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, req.UserID, req.ChannelID)
if err != nil {
return domain.ReadChannelTopicHistoryResult{}, err
}
if !channel.Forum {
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelForumMissing
}
if member.Guest {
return domain.ReadChannelTopicHistoryResult{}, domain.ErrChannelPrivate
}
topMax, err := s.channelTopicTopMessageID(ctx, req.ChannelID, req.TopicID, member.AvailableMinID)
if err != nil {
return domain.ReadChannelTopicHistoryResult{}, err

View file

@ -586,7 +586,7 @@ ORDER BY pinned DESC, pinned_order DESC, date DESC, topic_id DESC`, channelID, m
}
func (s *ChannelStore) ListChannelReplies(ctx context.Context, viewerUserID int64, filter domain.ChannelRepliesFilter) (domain.ChannelHistory, error) {
source, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, filter.ChannelID)
source, member, err := s.getChannelForMemberOrLinkedGuest(ctx, s.db, viewerUserID, filter.ChannelID)
if err != nil {
return domain.ChannelHistory{}, err
}