channels: give kicked/banned/promoted/transferred users a real qts so their client applies it
updateChannelParticipant carries the account's qts per the MTProto spec, but the server always sent Qts: 0, so real clients silently discarded it as a stale duplicate -- the banned/kicked user's channel never vanished locally and no correct "removed by admin" message showed, even though the update was delivered successfully at the transport layer. Add a durable per-device qts queue (channel_participant_event_queue) sharing its qts number space with the existing secret-chat queue (one qts sequence per device, per spec), and use it to stamp a correct, monotonically increasing qts on the update for every device of the affected user -- for channel bans/kicks, admin promotion/demotion, and ownership transfer. A device offline when it happened can now recover the event via updates.getDifference instead of missing it permanently.
This commit is contained in:
parent
97711c9d2e
commit
206bde18e0
16 changed files with 482 additions and 42 deletions
|
|
@ -114,17 +114,20 @@ func (s *SecretChatStore) ListActiveSecretChatsByAuthKey(_ context.Context, auth
|
|||
return out, nil
|
||||
}
|
||||
|
||||
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。
|
||||
// EncryptedQueueStore 是 store.EncryptedQueueStore 的进程内实现。也实现
|
||||
// store.ChannelParticipantQueueStore:两者共用同一个 reserved 水位 map(一台
|
||||
// 设备一条 qts 序列),与 postgres 两张表共用一张 secret_qts_watermarks 对齐。
|
||||
type EncryptedQueueStore struct {
|
||||
mu sync.Mutex
|
||||
byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息
|
||||
reserved map[int64]int
|
||||
confirmed map[int64]int
|
||||
dedup map[emqDedupKey]int // → qts
|
||||
stateEvents []domain.EncryptedStateEvent
|
||||
delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true
|
||||
nextEventID int64
|
||||
files map[int64]domain.EncryptedFileRef // file id → 快照
|
||||
mu sync.Mutex
|
||||
byDevice map[int64][]domain.SecretChatMessage // receiverAuthKeyID → qts 升序消息
|
||||
byDeviceParticipant map[int64][]domain.DeviceChannelParticipantEvent // receiverAuthKeyID → qts 升序事件
|
||||
reserved map[int64]int
|
||||
confirmed map[int64]int
|
||||
dedup map[emqDedupKey]int // → qts
|
||||
stateEvents []domain.EncryptedStateEvent
|
||||
delivered map[int64]map[int64]bool // eventID → deviceAuthKeyID → true
|
||||
nextEventID int64
|
||||
files map[int64]domain.EncryptedFileRef // file id → 快照
|
||||
}
|
||||
|
||||
type emqDedupKey struct {
|
||||
|
|
@ -136,14 +139,56 @@ type emqDedupKey struct {
|
|||
// NewEncryptedQueueStore 创建内存实现。
|
||||
func NewEncryptedQueueStore() *EncryptedQueueStore {
|
||||
return &EncryptedQueueStore{
|
||||
byDevice: make(map[int64][]domain.SecretChatMessage),
|
||||
reserved: make(map[int64]int),
|
||||
confirmed: make(map[int64]int),
|
||||
dedup: make(map[emqDedupKey]int),
|
||||
delivered: make(map[int64]map[int64]bool),
|
||||
byDevice: make(map[int64][]domain.SecretChatMessage),
|
||||
byDeviceParticipant: make(map[int64][]domain.DeviceChannelParticipantEvent),
|
||||
reserved: make(map[int64]int),
|
||||
confirmed: make(map[int64]int),
|
||||
dedup: make(map[emqDedupKey]int),
|
||||
delivered: make(map[int64]map[int64]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneChannelParticipantEvent(ev domain.DeviceChannelParticipantEvent) domain.DeviceChannelParticipantEvent {
|
||||
return ev
|
||||
}
|
||||
|
||||
// AppendChannelParticipantEvent implements store.ChannelParticipantQueueStore,
|
||||
// reserving from the same per-device qts counter as AppendEncryptedMessage.
|
||||
func (s *EncryptedQueueStore) AppendChannelParticipantEvent(_ context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.reserved[ev.ReceiverAuthKeyID]++
|
||||
ev.Qts = s.reserved[ev.ReceiverAuthKeyID]
|
||||
stored := cloneChannelParticipantEvent(ev)
|
||||
s.byDeviceParticipant[ev.ReceiverAuthKeyID] = append(s.byDeviceParticipant[ev.ReceiverAuthKeyID], stored)
|
||||
return cloneChannelParticipantEvent(stored), nil
|
||||
}
|
||||
|
||||
func (s *EncryptedQueueStore) ListChannelParticipantEventsSince(_ context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
var out []domain.DeviceChannelParticipantEvent
|
||||
for _, ev := range s.byDeviceParticipant[receiverAuthKeyID] {
|
||||
if ev.Qts > sinceQts {
|
||||
out = append(out, cloneChannelParticipantEvent(ev))
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AckChannelParticipantEvents is a no-op in the memory store: unlike postgres
|
||||
// it does no row-level GC, and confirmed_qts is already advanced by
|
||||
// AckEncryptedMessages against the shared reserved/confirmed watermark.
|
||||
func (s *EncryptedQueueStore) AckChannelParticipantEvents(_ context.Context, _ int64, _ int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneSecretMessage(m domain.SecretChatMessage) domain.SecretChatMessage {
|
||||
m.Bytes = append([]byte(nil), m.Bytes...)
|
||||
if m.File != nil {
|
||||
|
|
|
|||
149
internal/store/postgres/channel_participant_queue.go
Normal file
149
internal/store/postgres/channel_participant_queue.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// ChannelParticipantQueueStore is the PostgreSQL implementation of
|
||||
// store.ChannelParticipantQueueStore. It reserves qts from the same
|
||||
// secret_qts_watermarks table as EncryptedQueueStore (one qts sequence per
|
||||
// device, shared across event kinds), keeping this table's own schema and
|
||||
// queries entirely separate from the encrypted-message hot path.
|
||||
type ChannelParticipantQueueStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewChannelParticipantQueueStore builds the store on a pgx pool or transaction.
|
||||
func NewChannelParticipantQueueStore(db sqlcgen.DBTX) *ChannelParticipantQueueStore {
|
||||
return &ChannelParticipantQueueStore{db: db}
|
||||
}
|
||||
|
||||
const channelParticipantEventColumns = `receiver_auth_key_id, qts, receiver_user_id, channel_id, actor_user_id,
|
||||
date, previous_participant, new_participant`
|
||||
|
||||
func scanChannelParticipantEvent(row rowScanner) (domain.DeviceChannelParticipantEvent, error) {
|
||||
var ev domain.DeviceChannelParticipantEvent
|
||||
var previousRaw, newRaw []byte
|
||||
if err := row.Scan(
|
||||
&ev.ReceiverAuthKeyID, &ev.Qts, &ev.ReceiverUserID, &ev.ChannelID, &ev.ActorUserID,
|
||||
&ev.Date, &previousRaw, &newRaw,
|
||||
); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, err
|
||||
}
|
||||
if len(previousRaw) > 0 {
|
||||
if err := json.Unmarshal(previousRaw, &ev.Previous); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("unmarshal previous participant: %w", err)
|
||||
}
|
||||
}
|
||||
if len(newRaw) > 0 {
|
||||
if err := json.Unmarshal(newRaw, &ev.Participant); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("unmarshal new participant: %w", err)
|
||||
}
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (s *ChannelParticipantQueueStore) begin(ctx context.Context, op string) (pgx.Tx, error) {
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: db does not support transactions", op)
|
||||
}
|
||||
tx, err := beginner.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin %s: %w", op, err)
|
||||
}
|
||||
return tx, nil
|
||||
}
|
||||
|
||||
// AppendChannelParticipantEvent reserves the receiving device's next qts
|
||||
// (shared secret_qts_watermarks sequence) and writes the event, in one
|
||||
// transaction so the device's qts never has a hole.
|
||||
func (s *ChannelParticipantQueueStore) AppendChannelParticipantEvent(ctx context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error) {
|
||||
tx, err := s.begin(ctx, "append channel participant event")
|
||||
if err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
var qts int
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO secret_qts_watermarks (auth_key_id, reserved_qts)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (auth_key_id) DO UPDATE SET reserved_qts = secret_qts_watermarks.reserved_qts + 1, updated_at = now()
|
||||
RETURNING reserved_qts`, ev.ReceiverAuthKeyID).Scan(&qts); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("reserve device qts: %w", err)
|
||||
}
|
||||
ev.Qts = qts
|
||||
|
||||
previousRaw, err := marshalJSON(ev.Previous, "null")
|
||||
if err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("marshal previous participant: %w", err)
|
||||
}
|
||||
newRaw, err := marshalJSON(ev.Participant, "null")
|
||||
if err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("marshal new participant: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO channel_participant_event_queue (receiver_auth_key_id, qts, receiver_user_id, channel_id, actor_user_id,
|
||||
date, previous_participant, new_participant)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
ev.ReceiverAuthKeyID, ev.Qts, ev.ReceiverUserID, ev.ChannelID, ev.ActorUserID,
|
||||
ev.Date, previousRaw, newRaw); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("insert channel participant event: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return domain.DeviceChannelParticipantEvent{}, fmt.Errorf("commit append channel participant event: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func (s *ChannelParticipantQueueStore) ListChannelParticipantEventsSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error) {
|
||||
if limit <= 0 || limit > 1001 {
|
||||
limit = 1000
|
||||
}
|
||||
rows, err := s.db.Query(ctx,
|
||||
`SELECT `+channelParticipantEventColumns+` FROM channel_participant_event_queue
|
||||
WHERE receiver_auth_key_id = $1 AND qts > $2 ORDER BY qts ASC LIMIT $3`,
|
||||
receiverAuthKeyID, sinceQts, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list channel participant events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []domain.DeviceChannelParticipantEvent
|
||||
for rows.Next() {
|
||||
ev, err := scanChannelParticipantEvent(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ChannelParticipantQueueStore) AckChannelParticipantEvents(ctx context.Context, receiverAuthKeyID int64, maxQts int) error {
|
||||
if maxQts <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
UPDATE channel_participant_event_queue SET acked = true
|
||||
WHERE receiver_auth_key_id = $1 AND qts <= $2 AND NOT acked`, receiverAuthKeyID, maxQts); err != nil {
|
||||
return fmt.Errorf("ack channel participant events: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -59,3 +59,20 @@ type EncryptedQueueStore interface {
|
|||
// GetEncryptedFile 按 id + access_hash 回查文件快照(inputEncryptedFile 复用路径)。
|
||||
GetEncryptedFile(ctx context.Context, id, accessHash int64) (domain.EncryptedFileRef, bool, error)
|
||||
}
|
||||
|
||||
// ChannelParticipantQueueStore 持久化 channel 成员关系自通知(kick/ban/promote/
|
||||
// transfer)的设备级 qts 投递队列。updateChannelParticipant 走 qts 序列(MTProto
|
||||
// 规范),与 EncryptedQueueStore 共用同一张 secret_qts_watermarks 水位表——每设备
|
||||
// 只有一条 qts 序列,两类事件按 qts 交错,通过各自独立的表分别落盘,读侧
|
||||
// (updates.getDifference)按 qts 归并成连续序列。
|
||||
type ChannelParticipantQueueStore interface {
|
||||
// AppendChannelParticipantEvent 为接收设备分配下一个 qts 并写入队列,与
|
||||
// reserved_qts 推进同事务(与 AppendEncryptedMessage 共用同一水位表)。
|
||||
AppendChannelParticipantEvent(ctx context.Context, ev domain.DeviceChannelParticipantEvent) (domain.DeviceChannelParticipantEvent, error)
|
||||
// ListChannelParticipantEventsSince 返回接收设备 qts > sinceQts 的连续事件
|
||||
// (qts 升序,最多 limit)。
|
||||
ListChannelParticipantEventsSince(ctx context.Context, receiverAuthKeyID int64, sinceQts, limit int) ([]domain.DeviceChannelParticipantEvent, error)
|
||||
// AckChannelParticipantEvents 标记 qts<=maxQts 行为 acked(confirmed_qts 由
|
||||
// AckEncryptedMessages 在同一张水位表上推进,这里只做本表的 GC 标记)。
|
||||
AckChannelParticipantEvents(ctx context.Context, receiverAuthKeyID int64, maxQts int) error
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue