After channels.leaveChannel, a client that polls channels.getFullChannel kept receiving a projection that still showed it as an active member (left=false) until the per-(viewer,channel) RPC projection cache and the store-level member cache lapsed on their own or the async read-model NOTIFY landed. The client therefore kept an open compose box while every send was already rejected with CHANNEL_PRIVATE - most visible on public forum supergroups, where getFullChannel keeps succeeding via the preview path instead of tearing the chat down. Every other membership-mutating path already busts these caches synchronously; join/leave/invite/request-approval did not. Add: - store: invalidateChannelMembershipCaches (row + member + dialog caches), called post-commit from JoinChannel, LeaveChannel, ImportInvite, InviteToChannel. - rpc: invalidateChannelMembershipProjection (channelFullProjectionCache pair), called from the join/leave/invite/hide-requests handlers for every user whose membership changed.
143 lines
5.4 KiB
Go
143 lines
5.4 KiB
Go
package postgres
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"telesrv/internal/domain"
|
|
)
|
|
|
|
func (s *ChannelStore) InviteToChannel(ctx context.Context, channelID, inviterUserID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
|
|
if channelID == 0 || inviterUserID == 0 || len(userIDs) == 0 {
|
|
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
|
|
}
|
|
beginner, ok := s.db.(txBeginner)
|
|
if !ok {
|
|
return domain.CreateChannelResult{}, fmt.Errorf("invite channel: db does not support transactions")
|
|
}
|
|
tx, err := beginner.Begin(ctx)
|
|
if err != nil {
|
|
return domain.CreateChannelResult{}, fmt.Errorf("begin invite channel: %w", err)
|
|
}
|
|
committed := false
|
|
defer func() {
|
|
if !committed {
|
|
_ = tx.Rollback(ctx)
|
|
}
|
|
}()
|
|
channel, inviter, err := s.getChannelForMember(ctx, tx, inviterUserID, channelID)
|
|
if err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if channel.Monoforum {
|
|
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
|
|
}
|
|
if !canInviteToChannel(channel, inviter) {
|
|
return domain.CreateChannelResult{}, domain.ErrChannelAdminRequired
|
|
}
|
|
if date == 0 {
|
|
date = nowUnix()
|
|
}
|
|
requested := uniqueChannelUserIDs(userIDs, 0)
|
|
sort.Slice(requested, func(i, j int) bool { return requested[i] < requested[j] })
|
|
inviteOne := len(requested) == 1
|
|
canRestoreKicked := canBanChannelUsers(inviter)
|
|
invitedIDs := make([]int64, 0, len(requested))
|
|
members := make([]domain.ChannelMember, 0, len(requested))
|
|
restoredKicked := 0
|
|
existingMembers, err := channelMembersForUpdateBatchTx(ctx, tx, channelID, requested)
|
|
if err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
for _, userID := range requested {
|
|
if existing, ok := existingMembers[userID]; ok {
|
|
if existing.Status == domain.ChannelMemberActive {
|
|
if inviteOne {
|
|
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
|
|
}
|
|
continue
|
|
}
|
|
if existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages {
|
|
if !canRestoreKicked {
|
|
if inviteOne {
|
|
return domain.CreateChannelResult{}, domain.ErrUserKicked
|
|
}
|
|
continue
|
|
}
|
|
if existing.Status == domain.ChannelMemberKicked {
|
|
restoredKicked++
|
|
}
|
|
}
|
|
}
|
|
member := domain.ChannelMember{
|
|
ChannelID: channelID,
|
|
UserID: userID,
|
|
InviterUserID: inviterUserID,
|
|
Role: domain.ChannelRoleMember,
|
|
Status: domain.ChannelMemberActive,
|
|
JoinedAt: date,
|
|
AvailableMinID: channelInitialAvailableMinID(channel),
|
|
AvailableMinPts: channelInitialAvailableMinPts(channel),
|
|
ReadInboxMaxID: channel.TopMessageID,
|
|
}
|
|
members = append(members, member)
|
|
invitedIDs = append(invitedIDs, userID)
|
|
}
|
|
if len(members) > 0 {
|
|
if err := enableChannelMembershipBatchTx(ctx, tx); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if err := upsertChannelMembersBatchTx(ctx, tx, channel, members); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if err := insertChannelInviteAdminLogsBatchTx(ctx, tx, channelID, inviterUserID, date, members); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + $2, kicked_count = GREATEST(kicked_count - $3, 0), updated_at = now() WHERE id = $1`, channelID, len(members), restoredKicked); err != nil {
|
|
return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err)
|
|
}
|
|
channel.ParticipantsCount += len(members)
|
|
channel.KickedCount = maxInt(channel.KickedCount-restoredKicked, 0)
|
|
}
|
|
var msg domain.ChannelMessage
|
|
var event domain.ChannelUpdateEvent
|
|
if len(members) > 0 && channel.Megagroup {
|
|
msg, event, err = s.insertServiceMessage(ctx, tx, channel, inviterUserID, date, domain.ChannelMessageAction{
|
|
Type: domain.ChannelActionChatAddUser,
|
|
UserIDs: invitedIDs,
|
|
})
|
|
if err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
channel.TopMessageID = msg.ID
|
|
channel.Pts = event.Pts
|
|
}
|
|
if err := upsertChannelDialogsBatchTx(ctx, tx, channel, msg, members); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
// 被重新拉入群也是重进:按新 available_min_id 集合重算未读 reaction 计数清幽灵角标。
|
|
if err := refreshChannelUnreadReactionsCountsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if err := enqueueWelcomeMessageDeliveriesTx(ctx, tx, channel.ID, members); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if err := bumpChannelMembershipReadModelsBatchTx(ctx, tx, channel.ID, invitedIDs); err != nil {
|
|
return domain.CreateChannelResult{}, err
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return domain.CreateChannelResult{}, fmt.Errorf("commit invite channel: %w", err)
|
|
}
|
|
committed = true
|
|
s.invalidateChannelMembershipCaches(channelID, invitedIDs...)
|
|
recipients, _ := s.ListActiveChannelMemberIDs(ctx, inviterUserID, channelID, 0)
|
|
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
|
|
}
|
|
|
|
func canInviteToChannel(channel domain.Channel, member domain.ChannelMember) bool {
|
|
if member.Role == domain.ChannelRoleCreator ||
|
|
(member.Role == domain.ChannelRoleAdmin && (member.AdminRights.InviteUsers || member.AdminRights.ChangeInfo)) {
|
|
return true
|
|
}
|
|
return channel.Megagroup && !channel.DefaultBannedRights.InviteUsers && !member.BannedRights.InviteUsers
|
|
}
|