fix: sync non-PTS privacy updates

This commit is contained in:
iamxvbaba 2026-07-24 11:57:00 +08:00
parent 6cafa40c7b
commit cc76cd3679
17 changed files with 196 additions and 421 deletions

View file

@ -1,17 +1 @@
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);
DROP TABLE IF EXISTS public.user_update_privacy_payloads;
-- Reserved development migration version; up is intentionally a no-op.

View file

@ -1,30 +1,5 @@
-- Privacy settings are absolute account state. Persist the immutable rule
-- snapshot next to the account pts event so other online sessions and offline
-- getDifference replay exactly the committed value without re-querying the
-- mutable account_privacy_rules row.
CREATE TABLE public.user_update_privacy_payloads (
user_id bigint NOT NULL,
pts integer NOT NULL CHECK (pts > 0),
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
PRIMARY KEY (user_id, pts),
CONSTRAINT user_update_privacy_payloads_event_fk
FOREIGN KEY (user_id, pts)
REFERENCES public.user_update_events(user_id, pts)
ON DELETE CASCADE
);
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
'new_message', 'read_history_inbox', 'read_history_outbox', 'read_message_contents',
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'privacy', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox'
]::text[])
);
-- Reserved development migration version.
-- account privacy is authoritative absolute state: updatePrivacy has no
-- pts/pts_count, so this migration must not add a privacy event type or payload
-- table. The earlier development-only definition was corrected in place
-- because no user/production database can contain that unpublished shape.

View file

@ -1,14 +1,5 @@
-- Preserve account pts continuity if this development migration is reversed:
-- a profile refresh is absolute state, so replacing it with noop is safer than
-- deleting business history and leaving a permanent difference gap.
UPDATE public.user_update_events
SET event_type = 'noop', peer_type = NULL, peer_id = NULL
WHERE event_type = 'user_profile';
UPDATE public.dispatch_outbox
SET event_type = 'noop'
WHERE event_type = 'user_profile';
-- The corrected up migration does not add schema, payloads or event types.
-- Keep the constraint normalized to the same non-moderation event set.
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
@ -16,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'privacy', 'delete_messages',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',

View file

@ -1,7 +1,8 @@
-- SCAM/FAKE is viewer-visible absolute profile state. Each affected direct
-- contact/dialog viewer receives a durable account-pts refresh so online
-- outbox delivery and offline getDifference converge even when TDesktop keeps
-- its local contact/dialog cache across restarts.
-- Reserved development migration version.
-- SCAM/FAKE is authoritative peer profile state. updateUser/updateChannel have
-- no pts/pts_count, so this migration must not admit a moderation-only account
-- PTS event type. The earlier development definition was corrected in place
-- because no user/production database can contain that unpublished shape.
ALTER TABLE public.user_update_events DROP CONSTRAINT IF EXISTS user_update_events_type_check;
ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_check CHECK (
(event_type)::text = ANY (ARRAY[
@ -9,7 +10,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'edit_message', 'web_page', 'message_reactions', 'message_poll', 'draft_message', 'quick_replies',
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'user_profile', 'privacy',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status',
'delete_messages', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',

View file

@ -87,34 +87,6 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
return nil
}
func (c *CachedPrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
writer, ok := c.inner.(store.PrivacyUpdateStore)
if !ok {
return domain.UpdateEvent{}, domain.ErrPrivacyRuleInvalid
}
recorded, err := writer.SetPrivacyRulesWithUpdate(ctx, rules, event, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.UpdateEvent{}, err
}
c.InvalidateOwners(rules.OwnerUserID)
_ = c.WarmOwners(ctx, rules.OwnerUserID)
return recorded, nil
}
func (c *CachedPrivacyStore) SupportsDurablePrivacyUpdates() bool {
if c == nil {
return false
}
capability, ok := c.inner.(interface{ SupportsDurablePrivacyUpdates() bool })
return ok && capability.SupportsDurablePrivacyUpdates()
}
// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先
// InvalidateOwnersepoch 保证预热期间若又发生失效,不会把旧快照写回。
func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error {

View file

@ -83,39 +83,6 @@ func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.Pr
return out, nil
}
// SetRulesWithUpdate uses the production atomic write boundary when available.
// durable=false means no write was attempted; the RPC layer may then use the
// ordinary SetRules + Updates.RecordPrivacy fallback used by memory tests.
func (s *Service) SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.PrivacyRules, domain.UpdateEvent, bool, error) {
out, err := normalizedRules(ownerUserID, key, rules)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
capability, ok := s.rules.(interface{ SupportsDurablePrivacyUpdates() bool })
if !ok || !capability.SupportsDurablePrivacyUpdates() {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, nil
}
writer := s.rules.(store.PrivacyUpdateStore)
event, err := writer.SetPrivacyRulesWithUpdate(ctx, out, domain.UpdateEvent{
Type: domain.UpdateEventPrivacy,
Date: date,
Privacy: cloneRules(out),
PtsCount: 1,
}, excludeAuthKeyID, excludeSessionID)
if err != nil {
return domain.PrivacyRules{}, domain.UpdateEvent{}, false, err
}
return out, event, true, nil
}
func normalizedRules(ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
if !ValidKey(key) {
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid

View file

@ -607,19 +607,6 @@ func (s *Service) RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]b
}, true, excludeSessionID)
}
// RecordPrivacy durably synchronizes the exact immutable privacy snapshot to
// the account's other sessions and offline difference stream.
func (s *Service) RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error) {
if rules.OwnerUserID != userID || rules.Key == "" || len(rules.Rules) == 0 {
return domain.UpdateEvent{}, domain.UpdateState{}, domain.ErrPrivacyRuleInvalid
}
return s.recordEvent(ctx, stateAuthKeyID, excludeAuthKeyID, userID, domain.UpdateEvent{
Type: domain.UpdateEventPrivacy,
Privacy: rules,
PtsCount: 1,
}, true, excludeSessionID)
}
// RecordDraftMessage 记录某会话云草稿变化(保存/清空都是同一事件——草稿是绝对
// 状态,重放时按 peer 重载当前值。updateDraftMessage 无 pts 字段,走 LacksWirePts
// aux 簿记topMsgID 是 forum 话题草稿键(复用 MaxID 列持久化)。

View file

@ -40,10 +40,7 @@ const (
// user's absolute profile flags (currently moderation SCAM/FAKE state).
// The changed user is carried in Peer; hydration attaches the authoritative
// viewer projection to Users before online dispatch or getDifference.
UpdateEventUserProfile UpdateEventType = "user_profile"
// UpdateEventPrivacy carries the immutable account privacy key/rule
// snapshot committed at this pts. updatePrivacy has no wire pts.
UpdateEventPrivacy UpdateEventType = "privacy"
UpdateEventUserProfile UpdateEventType = "user_profile"
UpdateEventDeleteMessages UpdateEventType = "delete_messages"
// UpdateEventPinnedMessages 映射 updatePinnedMessages私聊置顶/取消
// 置顶MessageIDs 是该 owner 自己视角的 box idBool 为 pinned
@ -96,7 +93,6 @@ type UpdateEvent struct {
Bool bool
Phone string
EmojiStatus UserEmojiStatus
Privacy PrivacyRules
Settings PeerSettings
MessageIDs []int
MaxID int
@ -150,7 +146,6 @@ func (e UpdateEvent) LacksWirePts() bool {
UpdateEventUserPhone,
UpdateEventUserEmojiStatus,
UpdateEventUserProfile,
UpdateEventPrivacy,
UpdateEventDialogFilter,
UpdateEventDialogFilterOrder,
UpdateEventDialogFilters,

View file

@ -873,27 +873,7 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
if r.deps.Privacy == nil {
return &tg.AccountPrivacyRules{Rules: tgPrivacyRules(rules), Users: []tg.UserClass{}, Chats: []tg.ChatClass{}}, nil
}
authKeyID, _ := AuthKeyIDFrom(ctx)
sessionID, _ := SessionIDFrom(ctx)
var (
saved domain.PrivacyRules
event domain.UpdateEvent
durableWrite bool
)
if durable, ok := r.deps.Privacy.(PrivacyDurableService); ok {
saved, event, durableWrite, err = durable.SetRulesWithUpdate(
ctx,
userID,
domainKey,
rules,
int(r.clock.Now().Unix()),
rawAuthKeyIDForOrigin(ctx),
sessionID,
)
}
if err == nil && !durableWrite {
saved, err = r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
}
saved, err := r.deps.Privacy.SetRules(ctx, userID, domainKey, rules)
if err != nil {
return nil, privacyErr(err)
}
@ -902,33 +882,20 @@ func (r *Router) onAccountSetPrivacy(ctx context.Context, req *tg.AccountSetPriv
return nil, err
}
r.invalidateRPCProjectionForUser(userID)
if durableWrite {
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else if updates, ok := r.deps.Updates.(PrivacyUpdatesService); ok {
event, _, recordErr := updates.RecordPrivacy(
ctx, authKeyID, userID, saved, rawAuthKeyIDForOrigin(ctx), sessionID,
)
if recordErr != nil {
return nil, internalErr()
}
if sessionID != 0 {
r.bookkeepAuxPtsForCurrentSession(ctx, event)
}
r.pushUserUpdatesIfNoReliableDispatch(ctx, userID, tgUpdateForOutboxEvent(event))
} else {
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
})
}
// updatePrivacy is an absolute, non-PTS account-state notification. The
// originating session applies account.setPrivacy's response; other online
// sessions receive this best-effort update, while offline sessions reload
// the authoritative rules through account.getPrivacy.
r.pushUserUpdates(ctx, userID, &tg.Updates{
Updates: []tg.UpdateClass{&tg.UpdatePrivacy{
Key: tgPrivacyKey(saved.Key),
Rules: tgPrivacyRules(saved.Rules),
}},
Users: []tg.UserClass{},
Chats: []tg.ChatClass{},
Date: int(r.clock.Now().Unix()),
Seq: 0,
})
if domainKey == domain.PrivacyKeyStatusTimestamp {
r.pushStatusPrivacyRefresh(ctx, userID)
}

View file

@ -14,7 +14,7 @@ import (
"telesrv/internal/store/memory"
)
func TestAccountPrivacyAllKeysRoundTripAndRecordDifferenceEvents(t *testing.T) {
func TestAccountPrivacyAllKeysRoundTripWithoutAdvancingPts(t *testing.T) {
ctx := context.Background()
const userID int64 = 8101
authKeyID := [8]byte{8, 1}
@ -22,10 +22,11 @@ func TestAccountPrivacyAllKeysRoundTripAndRecordDifferenceEvents(t *testing.T) {
privacy := appprivacy.NewService(memory.NewPrivacyStore(), memory.NewContactStore())
events := memory.NewUpdateEventStore()
updates := appupdates.NewService(memory.NewUpdateStateStore(), events)
sessions := &captureSessions{}
router := New(Config{}, Deps{
Privacy: privacy,
Updates: updates,
Sessions: &captureSessions{},
Sessions: sessions,
}, zaptest.NewLogger(t), clock.System)
requestCtx := WithSessionID(WithAuthKeyID(WithUserID(ctx, userID), authKeyID), sessionID)
@ -83,40 +84,78 @@ func TestAccountPrivacyAllKeysRoundTripAndRecordDifferenceEvents(t *testing.T) {
if _, ok := get.Rules[0].(*tg.PrivacyValueDisallowAll); !ok {
t.Fatalf("getPrivacy rule=%T, want disallowAll", get.Rules[0])
}
pushed, ok := sessions.lastUserPush().(*tg.Updates)
if !ok || len(pushed.Updates) != 1 {
t.Fatalf("online push=%T/%+v, want one updatePrivacy", sessions.lastUserPush(), pushed)
}
privacyUpdate, ok := pushed.Updates[0].(*tg.UpdatePrivacy)
if !ok {
t.Fatalf("online push update=%T, want updatePrivacy(%q)", pushed.Updates[0], test.domain)
}
if !test.wire(privacyUpdate.Key) {
t.Fatalf("online push key=%T, want %q", privacyUpdate.Key, test.domain)
}
})
}
if pushedUserIDs := sessions.pushedUserIDs(); len(pushedUserIDs) != len(keys) {
t.Fatalf("online privacy pushes=%v, want exactly one per key", pushedUserIDs)
} else {
for i, pushedUserID := range pushedUserIDs {
if pushedUserID != userID {
t.Fatalf("online privacy push[%d] target=%d, want owner %d", i, pushedUserID, userID)
}
}
}
if snapshot := sessions.snapshot(); snapshot.sessionID != sessionID || snapshot.userID != userID {
t.Fatalf("online push exclusion/target=%+v, want current session %d excluded for user %d", snapshot, sessionID, userID)
}
recorded, err := events.ListAfter(ctx, userID, 0, 100)
if err != nil {
t.Fatalf("list privacy events: %v", err)
t.Fatalf("list account update events: %v", err)
}
if len(recorded) != len(keys) {
t.Fatalf("privacy events=%d, want %d", len(recorded), len(keys))
if len(recorded) != 0 {
t.Fatalf("account update events=%+v, want none for privacy changes", recorded)
}
for i, event := range recorded {
if event.Type != domain.UpdateEventPrivacy ||
event.Privacy.OwnerUserID != userID ||
event.Privacy.Key != keys[i].domain ||
event.PtsCount != 1 {
t.Fatalf("event[%d]=%+v, want durable privacy snapshot for %q", i, event, keys[i].domain)
}
state, err := updates.CurrentState(ctx, userID)
if err != nil {
t.Fatalf("current update state: %v", err)
}
if state.Pts != 0 {
t.Fatalf("privacy changes advanced pts to %d, want 0", state.Pts)
}
difference, err := updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference: %v", err)
}
wireDifference, ok := tgUpdatesDifference(userID, difference).(*tg.UpdatesDifference)
if !ok || len(wireDifference.OtherUpdates) != len(keys) {
t.Fatalf("wire difference=%T updates=%d, want %d privacy updates", wireDifference, len(wireDifference.OtherUpdates), len(keys))
if difference.State.Pts != 0 || len(difference.Events) != 0 {
t.Fatalf("difference after privacy changes=%+v, want empty pts=0", difference)
}
for i, update := range wireDifference.OtherUpdates {
privacyUpdate, ok := update.(*tg.UpdatePrivacy)
if !ok {
t.Fatalf("difference update[%d]=%T, want updatePrivacy", i, update)
}
if !keys[i].wire(privacyUpdate.Key) {
t.Fatalf("difference update[%d] key=%T, want %q", i, privacyUpdate.Key, keys[i].domain)
}
// A real message-box update immediately after privacy changes must still
// receive pts=1. This catches both hidden privacy allocations and gaps left
// behind by synthetic bookkeeping events.
message := domain.Message{
ID: 1,
OwnerUserID: userID,
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 8102},
From: domain.Peer{Type: domain.PeerTypeUser, ID: 8102},
Date: 1700000000,
Body: "after privacy",
}
event, state, err := updates.RecordNewMessage(ctx, authKeyID, userID, message)
if err != nil {
t.Fatalf("record adjacent message update: %v", err)
}
if event.Pts != 1 || event.PtsCount != 1 || state.Pts != 1 {
t.Fatalf("adjacent message event/state=%+v/%+v, want first pts=1", event, state)
}
difference, err = updates.GetDifference(ctx, [8]byte{8, 2}, userID, domain.UpdateState{})
if err != nil {
t.Fatalf("getDifference after message: %v", err)
}
if difference.State.Pts != 1 || len(difference.Events) != 1 || difference.Events[0].Type != domain.UpdateEventNewMessage {
t.Fatalf("difference after adjacent message=%+v, want one contiguous new_message at pts=1", difference)
}
}

View file

@ -247,14 +247,6 @@ func tgOtherUpdateFromEvent(event domain.UpdateEvent) tg.UpdateClass {
return nil
}
return &tg.UpdateUser{UserID: event.Peer.ID}
case domain.UpdateEventPrivacy:
if event.Privacy.OwnerUserID == 0 || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return nil
}
return &tg.UpdatePrivacy{
Key: tgPrivacyKey(event.Privacy.Key),
Rules: tgPrivacyRules(event.Privacy.Rules),
}
case domain.UpdateEventChannelState:
if event.Peer.Type != domain.PeerTypeChannel || event.Peer.ID == 0 {
return nil

View file

@ -487,26 +487,6 @@ type UserEmojiStatusUpdatesService interface {
RecordUserEmojiStatus(ctx context.Context, stateAuthKeyID [8]byte, userID int64, status domain.UserEmojiStatus, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyUpdatesService is the fallback durable extension for stores that do
// not support the atomic privacy+event write boundary (mainly memory tests).
type PrivacyUpdatesService interface {
RecordPrivacy(ctx context.Context, stateAuthKeyID [8]byte, userID int64, rules domain.PrivacyRules, excludeAuthKeyID [8]byte, excludeSessionID int64) (domain.UpdateEvent, domain.UpdateState, error)
}
// PrivacyDurableService is implemented by the production privacy service. Its
// successful path commits rules+pts+event+dispatch in one transaction.
type PrivacyDurableService interface {
SetRulesWithUpdate(
ctx context.Context,
ownerUserID int64,
key domain.PrivacyKey,
rules []domain.PrivacyRule,
date int,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (saved domain.PrivacyRules, event domain.UpdateEvent, durable bool, err error)
}
// ContactsService 抽象通讯录查询。
type ContactsService interface {
GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error)

View file

@ -105,20 +105,9 @@ func cloneUpdateEvent(event domain.UpdateEvent) domain.UpdateEvent {
event.Reaction = cloneUpdateReaction(event.Reaction)
event.QuickReplies = cloneUpdateQuickReplies(event.QuickReplies)
event.QuickReplyMessage = cloneUpdateQuickReplyMessage(event.QuickReplyMessage)
event.Privacy.Rules = clonePrivacyRuleSlice(event.Privacy.Rules)
return event
}
func clonePrivacyRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
out := make([]domain.PrivacyRule, len(in))
for i := range in {
out[i] = in[i]
out[i].UserIDs = append([]int64(nil), in[i].UserIDs...)
out[i].ChatIDs = append([]int64(nil), in[i].ChatIDs...)
}
return out
}
func cloneUpdateStory(story domain.Story) domain.Story {
story.Entities = append([]domain.MessageEntity(nil), story.Entities...)
story.Views.Reactions = append([]domain.ChannelMessageReactionCount(nil), story.Views.Reactions...)

View file

@ -24,16 +24,6 @@ func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore {
return &PrivacyStore{db: db}
}
func (s *PrivacyStore) SupportsDurablePrivacyUpdates() bool {
if s == nil {
return false
}
_, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
return ok
}
func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
row := s.db.QueryRow(ctx, `
SELECT rules::text
@ -77,56 +67,6 @@ ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
return nil
}
// SetPrivacyRulesWithUpdate commits the mutable rule row and the immutable
// account update snapshot in one transaction. A privacy rule can therefore
// never become visible without a matching pts event/outbox item.
func (s *PrivacyStore) SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error) {
beginner, ok := s.db.(interface {
Begin(context.Context) (pgx.Tx, error)
})
if !ok {
return domain.UpdateEvent{}, fmt.Errorf("privacy update transaction unavailable")
}
tx, err := beginner.Begin(ctx)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("begin privacy update: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(ctx)
}
}()
if err := setPrivacyRules(ctx, tx, rules); err != nil {
return domain.UpdateEvent{}, err
}
if event.Date == 0 {
return domain.UpdateEvent{}, fmt.Errorf("privacy update date is required")
}
event.Type = domain.UpdateEventPrivacy
event.Privacy = rules
event.PtsCount = 1
qtx := sqlcgen.New(tx)
recorded, err := NewUpdateEventStore(tx).appendInTx(
ctx, tx, qtx, rules.OwnerUserID, event, true,
excludeAuthKeyID, excludeSessionID, true,
)
if err != nil {
return domain.UpdateEvent{}, fmt.Errorf("append privacy update: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return domain.UpdateEvent{}, fmt.Errorf("commit privacy update: %w", err)
}
committed = true
return recorded, nil
}
func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
if len(ownerUserIDs) == 0 || len(keys) == 0 {
return nil, nil

View file

@ -0,0 +1,103 @@
package postgres
import (
"context"
"errors"
"testing"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
)
// TestPrivacyRulesDoNotAllocateAccountPts protects the protocol boundary:
// account privacy is authoritative absolute state, not a message-box event.
func TestPrivacyRulesDoNotAllocateAccountPts(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
users := NewUserStore(pool)
suffix := randomSuffix(t)
user, err := users.Create(ctx, domain.User{
AccessHash: 9201,
Phone: "+1665" + suffix + "01",
FirstName: "PrivacyPts",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", user.ID)
})
var privacyPayloadTableAbsent bool
if err := pool.QueryRow(ctx, `
SELECT to_regclass('public.user_update_privacy_payloads') IS NULL`).Scan(&privacyPayloadTableAbsent); err != nil {
t.Fatalf("inspect privacy payload schema: %v", err)
}
if !privacyPayloadTableAbsent {
t.Fatal("development-only user_update_privacy_payloads table still exists")
}
if _, err := pool.Exec(ctx, `
INSERT INTO user_update_events (user_id, pts, pts_count, date, event_type)
VALUES ($1, 1, 1, 1700000000, 'privacy')`, user.ID); err == nil {
t.Fatal("development-only privacy update event type is still accepted")
} else {
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.Code != pgerrcode.CheckViolation {
t.Fatalf("insert privacy update event error=%v, want check violation", err)
}
}
type updateFootprint struct {
eventCount int
maxPts int
outboxCount int
watermarkRow int
watermarkPts int
}
readFootprint := func() updateFootprint {
t.Helper()
var got updateFootprint
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(pts), 0)
FROM user_update_events
WHERE user_id = $1`, user.ID).Scan(&got.eventCount, &got.maxPts); err != nil {
t.Fatalf("read update events footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*)
FROM dispatch_outbox
WHERE target_user_id = $1`, user.ID).Scan(&got.outboxCount); err != nil {
t.Fatalf("read outbox footprint: %v", err)
}
if err := pool.QueryRow(ctx, `
SELECT count(*), COALESCE(max(contiguous_pts), 0)
FROM user_update_watermarks
WHERE user_id = $1`, user.ID).Scan(&got.watermarkRow, &got.watermarkPts); err != nil {
t.Fatalf("read update watermark footprint: %v", err)
}
return got
}
before := readFootprint()
store := NewPrivacyStore(pool)
want := domain.PrivacyRules{
OwnerUserID: user.ID,
Key: domain.PrivacyKeyPhoneNumber,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}
if err := store.SetPrivacyRules(ctx, want); err != nil {
t.Fatalf("set privacy rules: %v", err)
}
got, found, err := store.GetPrivacyRules(ctx, user.ID, want.Key)
if err != nil || !found {
t.Fatalf("get privacy rules: found=%v err=%v", found, err)
}
if len(got.Rules) != 1 || got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("stored privacy rules=%+v, want disallow_all", got)
}
after := readFootprint()
if after != before {
t.Fatalf("privacy write changed PTS footprint: before=%+v after=%+v", before, after)
}
}

View file

@ -244,35 +244,12 @@ func appendUserUpdateEvent(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Quer
}); err != nil {
return err
}
if err := appendPrivacyPayload(ctx, db, userID, event); err != nil {
return err
}
if err := appendQuickReplyPayload(ctx, db, userID, event); err != nil {
return err
}
return nil
}
func appendPrivacyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
if event.Type != domain.UpdateEventPrivacy {
return nil
}
if event.Privacy.OwnerUserID != userID || event.Privacy.Key == "" || len(event.Privacy.Rules) == 0 {
return domain.ErrPrivacyRuleInvalid
}
raw, err := json.Marshal(event.Privacy)
if err != nil {
return fmt.Errorf("encode privacy update payload: %w", err)
}
if _, err := db.Exec(ctx, `
INSERT INTO user_update_privacy_payloads (user_id, pts, payload)
VALUES ($1, $2, $3::jsonb)
`, userID, event.Pts, string(raw)); err != nil {
return fmt.Errorf("save privacy update payload: %w", err)
}
return nil
}
func appendQuickReplyPayload(ctx context.Context, db sqlcgen.DBTX, userID int64, event domain.UpdateEvent) error {
switch event.Type {
case domain.UpdateEventQuickReplies,
@ -489,9 +466,6 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
@ -691,77 +665,9 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
}
out = append(out, event)
}
if err := s.hydratePrivacyEvents(ctx, out); err != nil {
return nil, err
}
return out, nil
}
type privacyEventCursor struct {
userID int64
pts int
}
// hydratePrivacyEvents fetches all immutable privacy payloads for one
// difference/outbox batch in one query. Ordinary event batches incur no extra
// query at all.
func (s *UpdateEventStore) hydratePrivacyEvents(ctx context.Context, events []domain.UpdateEvent) error {
indexes := make(map[privacyEventCursor]int)
userIDs := make([]int64, 0)
pts := make([]int32, 0)
for i := range events {
if events[i].Type != domain.UpdateEventPrivacy {
continue
}
key := privacyEventCursor{userID: events[i].UserID, pts: events[i].Pts}
indexes[key] = i
userIDs = append(userIDs, key.userID)
pts = append(pts, int32(key.pts))
}
if len(indexes) == 0 {
return nil
}
rows, err := s.db.Query(ctx, `
SELECT p.user_id, p.pts, p.payload::text
FROM unnest($1::bigint[], $2::int[]) AS requested(user_id, pts)
JOIN user_update_privacy_payloads p USING (user_id, pts)
`, userIDs, pts)
if err != nil {
return fmt.Errorf("list privacy update payloads: %w", err)
}
defer rows.Close()
found := 0
for rows.Next() {
var userID int64
var eventPts int
var raw string
if err := rows.Scan(&userID, &eventPts, &raw); err != nil {
return fmt.Errorf("scan privacy update payload: %w", err)
}
index, ok := indexes[privacyEventCursor{userID: userID, pts: eventPts}]
if !ok {
continue
}
var payload domain.PrivacyRules
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fmt.Errorf("decode privacy update payload: %w", err)
}
if payload.OwnerUserID != userID || payload.Key == "" || len(payload.Rules) == 0 {
return fmt.Errorf("invalid privacy update payload for user %d pts %d", userID, eventPts)
}
events[index].Privacy = payload
delete(indexes, privacyEventCursor{userID: userID, pts: eventPts})
found++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("list privacy update payloads rows: %w", err)
}
if found != len(userIDs) || len(indexes) != 0 {
return fmt.Errorf("privacy update payload missing")
}
return nil
}
func usersFromUpdateEventRow(row sqlcgen.ListUserUpdateEventsAfterRow) []domain.User {
return mergeEventUsers(
domain.User{

View file

@ -12,16 +12,3 @@ type PrivacyStore interface {
SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error
ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error)
}
// PrivacyUpdateStore atomically commits an absolute privacy rule snapshot,
// allocates account pts, appends its durable event, and enqueues online
// dispatch. Implementations return the event with its final pts.
type PrivacyUpdateStore interface {
SetPrivacyRulesWithUpdate(
ctx context.Context,
rules domain.PrivacyRules,
event domain.UpdateEvent,
excludeAuthKeyID [8]byte,
excludeSessionID int64,
) (domain.UpdateEvent, error)
}