owpengram-server/internal/store/postgres/channel_member_join.go
Astra 2419f47236 channels: drop stale membership caches on join/leave
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.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 13:28:50 +01:00

313 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
func (s *ChannelStore) JoinChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) {
if channelID == 0 || userID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.CreateChannelResult{}, fmt.Errorf("join channel: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("begin join channel: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, err := getChannelByID(ctx, tx, channelID)
if err != nil {
return domain.CreateChannelResult{}, err
}
if channel.Monoforum {
return domain.CreateChannelResult{}, domain.ErrChannelMonoforumUnsupported
}
existing, existingErr := s.getChannelMember(ctx, tx, channelID, userID)
if existingErr == nil {
switch {
case existing.Status == domain.ChannelMemberActive:
return domain.CreateChannelResult{}, domain.ErrUserAlreadyParticipant
case existing.Status == domain.ChannelMemberBanned || existing.Status == domain.ChannelMemberKicked || existing.BannedRights.ViewMessages:
return domain.CreateChannelResult{}, domain.ErrChannelUserBanned
}
}
if date == 0 {
date = nowUnix()
}
if channel.JoinRequest {
if err := s.recordPublicJoinRequestTx(ctx, tx, channel, userID, date); err != nil {
return domain.CreateChannelResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit public channel join request: %w", err)
}
committed = true
return domain.CreateChannelResult{Channel: channel}, domain.ErrInviteRequestSent
}
preJoinTopID := channel.TopMessageID
minID := channelInitialAvailableMinID(channel)
// 自加入inviter 即本人(对齐官方 channelParticipantSelf.inviter_id == user_id。客户端据此
// TDesktop requestSelf→getParticipant→channel->inviter生成本地「您加入了此频道」服务消息
// 进而把广播频道补进会话列表(广播加入无服务端服务消息,靠这条本地消息物化会话)。
member := domain.ChannelMember{ChannelID: channelID, UserID: userID, InviterUserID: userID, Role: domain.ChannelRoleMember, Status: domain.ChannelMemberActive, JoinedAt: date, AvailableMinID: minID, AvailableMinPts: channelInitialAvailableMinPts(channel), ReadInboxMaxID: maxInt(minID, preJoinTopID)}
if existingErr == nil {
// 重进是全新 participant但部分限制名单独立于在群与否、重进后仍生效
// 只有 channel 当前 owner 仍是该账号时,才保留 creator 身份。
member.BannedRights = existing.BannedRights
if existing.Role == domain.ChannelRoleCreator && channel.CreatorUserID == userID {
member.Role = domain.ChannelRoleCreator
member.AdminRights = existing.AdminRights
member.Rank = existing.Rank
}
}
if err := upsertChannelMemberTx(ctx, tx, channel, member); err != nil {
return domain.CreateChannelResult{}, err
}
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: userID,
Date: date,
Type: domain.ChannelAdminLogParticipantJoin,
}); err != nil {
return domain.CreateChannelResult{}, err
}
if _, err := tx.Exec(ctx, `UPDATE channels SET participants_count = participants_count + 1, updated_at = now() WHERE id = $1`, channelID); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("update channel participants: %w", err)
}
channel.ParticipantsCount++
var msg domain.ChannelMessage
var event domain.ChannelUpdateEvent
if channel.Megagroup {
msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{
Type: domain.ChannelActionChatJoined,
UserIDs: []int64{userID},
})
if err != nil {
return domain.CreateChannelResult{}, err
}
channel.TopMessageID = msg.ID
channel.Pts = event.Pts
}
member.ReadInboxMaxID = maxInt(member.ReadInboxMaxID, channel.TopMessageID)
if msg.ID != 0 && msg.SenderUserID == userID {
member.ReadOutboxMaxID = maxInt(member.ReadOutboxMaxID, msg.ID)
}
if _, err := tx.Exec(ctx, `
UPDATE channel_members
SET read_inbox_max_id = GREATEST(read_inbox_max_id, $3),
read_outbox_max_id = GREATEST(read_outbox_max_id, $4),
unread_mark = false,
updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, channelID, userID, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("update joined channel read watermarks: %w", err)
}
if err := upsertChannelDialogTx(ctx, tx, userID, channel, msg, member.ReadInboxMaxID, member.ReadOutboxMaxID); err != nil {
return domain.CreateChannelResult{}, err
}
// 按新的 available_min_id 重算未读 reaction 计数:重进是全新 participant,旧
// channel_dialogs.unread_reactions_count 是从不在 join 路径重算的存储列,
// PreHistoryHidden/broadcast 重进后 available_min_id 越过旧 reaction,陈旧计数会残留
// 成幽灵角标。refreshChannelUnreadReactionsCountTx 门控 cm.id>available_min_id,对
// PreHistoryHidden/broadcast 算出 0(清幽灵)、对 prehistory-visible(min_id 仍 0)保留真实计数。
if err := refreshChannelUnreadReactionsCountTx(ctx, tx, userID, channelID); err != nil {
return domain.CreateChannelResult{}, err
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit join channel: %w", err)
}
committed = true
s.invalidateChannelMembershipCaches(channelID, userID)
recipients, _ := s.ListActiveChannelMemberIDs(ctx, userID, channelID, 0)
return domain.CreateChannelResult{Channel: channel, Members: []domain.ChannelMember{member}, Message: msg, Event: event, Recipients: recipients}, nil
}
func (s *ChannelStore) FutureCreatorAfterLeave(ctx context.Context, channelID, userID int64) (domain.ChannelMember, error) {
if channelID == 0 || userID == 0 {
return domain.ChannelMember{}, domain.ErrChannelInvalid
}
channel, member, err := s.getChannelForMember(ctx, s.db, userID, channelID)
if err != nil {
return domain.ChannelMember{}, err
}
if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator {
return domain.ChannelMember{}, domain.ErrChannelAdminRequired
}
return s.futureCreatorAfterLeave(ctx, s.db, channelID, userID)
}
func (s *ChannelStore) LeaveChannel(ctx context.Context, channelID, userID int64, date int) (domain.CreateChannelResult, error) {
if channelID == 0 || userID == 0 {
return domain.CreateChannelResult{}, domain.ErrChannelInvalid
}
beginner, ok := s.db.(txBeginner)
if !ok {
return domain.CreateChannelResult{}, fmt.Errorf("leave channel: db does not support transactions")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("begin leave channel: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
channel, member, err := s.getChannelForMember(ctx, tx, userID, channelID)
if err != nil {
return domain.CreateChannelResult{}, err
}
if date == 0 {
date = nowUnix()
}
members := []domain.ChannelMember{}
adminsDelta := 0
if isChannelAdmin(member) {
adminsDelta--
}
if channel.CreatorUserID == userID || member.Role == domain.ChannelRoleCreator {
if channel.CreatorUserID != userID || member.Role != domain.ChannelRoleCreator {
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
}
future, err := s.futureCreatorAfterLeave(ctx, tx, channelID, userID)
if err != nil {
if errors.Is(err, domain.ErrUserNotParticipant) {
return domain.CreateChannelResult{}, domain.ErrChannelUserCreator
}
return domain.CreateChannelResult{}, err
}
if !isChannelAdmin(future) {
adminsDelta++
}
future.Role = domain.ChannelRoleCreator
future.AdminRights = creatorChannelMember(channelID, future.UserID, date).AdminRights
future.Rank = ""
future.Status = domain.ChannelMemberActive
future.LeftAt = 0
channel.CreatorUserID = future.UserID
members = append(members, future)
member.Role = domain.ChannelRoleMember
member.AdminRights = domain.ChannelAdminRights{}
member.Rank = ""
}
member.Status = domain.ChannelMemberLeft
member.LeftAt = date
members = append([]domain.ChannelMember{member}, members...)
if _, err := tx.Exec(ctx, `
UPDATE channels
SET creator_user_id = $2,
participants_count = GREATEST(participants_count - 1, 0),
admins_count = GREATEST(admins_count + $3, 0),
updated_at = now()
WHERE id = $1`, channelID, channel.CreatorUserID, adminsDelta); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("update channel leave state: %w", err)
}
if channel.ParticipantsCount > 0 {
channel.ParticipantsCount--
}
channel.AdminsCount += adminsDelta
if channel.AdminsCount < 0 {
channel.AdminsCount = 0
}
for _, changed := range members {
if err := upsertChannelMemberTx(ctx, tx, channel, changed); err != nil {
return domain.CreateChannelResult{}, err
}
}
recipients, err := s.listActiveChannelMemberIDs(ctx, tx, channelID, 0)
if err != nil {
return domain.CreateChannelResult{}, err
}
if err := s.insertChannelAdminLogTx(ctx, tx, domain.ChannelAdminLogEvent{
ChannelID: channelID,
UserID: userID,
Date: date,
Type: domain.ChannelAdminLogParticipantLeave,
}); err != nil {
return domain.CreateChannelResult{}, err
}
if err := clearChannelMentionsForUserTx(ctx, tx, channelID, userID); err != nil {
return domain.CreateChannelResult{}, err
}
var msg domain.ChannelMessage
var event domain.ChannelUpdateEvent
if channel.Megagroup {
msg, event, err = s.insertServiceMessage(ctx, tx, channel, userID, date, domain.ChannelMessageAction{
Type: domain.ChannelActionChatDelete,
UserIDs: []int64{userID},
})
if err != nil {
return domain.CreateChannelResult{}, err
}
channel.TopMessageID = msg.ID
channel.Pts = event.Pts
}
if err := tx.Commit(ctx); err != nil {
return domain.CreateChannelResult{}, fmt.Errorf("commit leave channel: %w", err)
}
committed = true
leftUserIDs := make([]int64, 0, len(members))
for _, m := range members {
leftUserIDs = append(leftUserIDs, m.UserID)
}
s.invalidateChannelMembershipCaches(channelID, leftUserIDs...)
recipients = append(recipients, userID)
return domain.CreateChannelResult{Channel: channel, Members: members, Message: msg, Event: event, Recipients: recipients}, nil
}
func (s *ChannelStore) futureCreatorAfterLeave(ctx context.Context, db sqlcgen.DBTX, channelID, userID int64) (domain.ChannelMember, error) {
member, err := scanChannelMember(db.QueryRow(ctx, `
SELECT channel_id, user_id, inviter_user_id, role, status, joined_at, left_at,
admin_rights::text, banned_rights::text, rank, available_min_id, available_min_pts,
history_clear_anchor_id, history_clear_anchor_date,
read_inbox_max_id, read_outbox_max_id, unread_mark, slowmode_last_send_date
FROM channel_members
WHERE channel_id = $1
AND user_id <> $2
AND status = 'active'
AND role <> 'creator'
AND COALESCE((banned_rights->>'ViewMessages')::boolean, false) = false
ORDER BY CASE role WHEN 'admin' THEN 0 ELSE 1 END, user_id
LIMIT 1`, channelID, userID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return domain.ChannelMember{}, domain.ErrUserNotParticipant
}
return domain.ChannelMember{}, err
}
return member, nil
}
func (s *ChannelStore) listActiveChannelMemberIDs(ctx context.Context, db sqlcgen.DBTX, channelID int64, limit int) ([]int64, error) {
if limit <= 0 || limit > domain.MaxChannelRealtimeFanout {
limit = domain.MaxChannelRealtimeFanout
}
rows, err := db.Query(ctx, `SELECT user_id FROM channel_members WHERE channel_id = $1 AND status = 'active' ORDER BY user_id LIMIT $2`, channelID, limit)
if err != nil {
return nil, fmt.Errorf("list active channel members: %w", err)
}
defer rows.Close()
out := make([]int64, 0, limit)
for rows.Next() {
var userID int64
if err := rows.Scan(&userID); err != nil {
return nil, err
}
out = append(out, userID)
}
return out, rows.Err()
}