From e1a95c7318afefe16eafc78b27d3ffad99712d80 Mon Sep 17 00:00:00 2001 From: iamxvbaba <28732408+iamxvbaba@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:56:58 +0800 Subject: [PATCH] fix: sync contact phone privacy disclosure --- ...42_clear_ambiguous_contact_phones.down.sql | 4 + ...0142_clear_ambiguous_contact_phones.up.sql | 15 ++ internal/app/contacts/service.go | 129 +++++++++++- internal/app/contacts/service_test.go | 149 +++++++++++++- internal/app/privacy/cache.go | 25 +++ internal/app/privacy/cache_test.go | 26 ++- internal/app/userprojection/contact_cache.go | 188 +++++++++++++++++- .../app/userprojection/contact_cache_test.go | 75 +++++++ internal/app/userprojection/projection.go | 28 ++- .../app/userprojection/projection_test.go | 45 +++++ internal/app/users/service.go | 26 ++- internal/app/users/service_test.go | 37 ++++ internal/rpc/contacts_users_rpc_test.go | 19 +- internal/store/memory/contacts.go | 3 - internal/store/memory/story.go | 2 - internal/store/memory/story_test.go | 27 +++ .../postgres/channel_member_cache_test.go | 17 ++ .../store/postgres/read_model_listener.go | 20 ++ internal/store/postgres/story.go | 4 +- 19 files changed, 789 insertions(+), 50 deletions(-) create mode 100644 deploy/migrations/0142_clear_ambiguous_contact_phones.down.sql create mode 100644 deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql diff --git a/deploy/migrations/0142_clear_ambiguous_contact_phones.down.sql b/deploy/migrations/0142_clear_ambiguous_contact_phones.down.sql new file mode 100644 index 00000000..a1ea83b9 --- /dev/null +++ b/deploy/migrations/0142_clear_ambiguous_contact_phones.down.sql @@ -0,0 +1,4 @@ +-- Irreversible privacy cleanup: restoring users.phone here would recreate the +-- disclosure this migration removes. Contact relations and all non-phone +-- owner-scoped fields are preserved by the up migration. +SELECT 1; diff --git a/deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql b/deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql new file mode 100644 index 00000000..3fee9934 --- /dev/null +++ b/deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql @@ -0,0 +1,15 @@ +-- contacts.addContact historically replaced an omitted phone with users.phone. +-- Those rows are indistinguishable from a client-supplied copy of the same +-- number, so privacy-safe repair must treat every exact account-phone copy as +-- ambiguous. The contact relationship and owner-scoped names/notes remain; a +-- later contacts.importContacts sync can explicitly restore a known phone. +-- +-- This is a one-time write-path repair. Runtime reads must not normalize or +-- second-guess the bad shape. +UPDATE contacts AS c +SET contact_phone = '', + updated_at = now() +FROM users AS u +WHERE u.id = c.contact_user_id + AND c.contact_phone <> '' + AND c.contact_phone = u.phone; diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index e73a8139..a34ec1c1 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -22,6 +22,7 @@ const maxCloseFriendsCount = 5000 type phonePrivacyService interface { userprojection.PrivacyEvaluator + userprojection.BatchPrivacyEvaluator AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) } @@ -123,19 +124,17 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con return domain.Contact{}, ErrContactNameEmpty } // Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空), - // 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。 + // 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留; + // TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。 input.Phone = digitsOnly(input.Phone) if s.users != nil { - target, found, err := s.users.ByID(ctx, input.ContactUserID) + _, found, err := s.users.ByID(ctx, input.ContactUserID) if err != nil { return domain.Contact{}, err } if !found { return domain.Contact{}, ErrContactIDInvalid } - if input.Phone == "" { - input.Phone = target.Phone - } } contact, err := s.contacts.Upsert(ctx, userID, input) if err != nil { @@ -235,6 +234,30 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom if err != nil { return domain.ImportContactsResult{}, err } + if s.privacy != nil && len(targets) > 0 { + targetIDs := make([]int64, 0, len(targets)) + for _, target := range targets { + if target.ID != 0 && target.ID != userID { + targetIDs = append(targetIDs, target.ID) + } + } + visibility, err := s.privacy.CanSeeBatch( + ctx, + targetIDs, + userID, + []domain.PrivacyKey{domain.PrivacyKeyAddedByPhone}, + ) + if err != nil { + return domain.ImportContactsResult{}, err + } + allowed := targets[:0] + for _, target := range targets { + if visibility[target.ID][domain.PrivacyKeyAddedByPhone] { + allowed = append(allowed, target) + } + } + targets = allowed + } byPhone := make(map[string]domain.User, len(targets)) for _, target := range targets { if target.Phone != "" { @@ -310,10 +333,52 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit if limit <= 0 || limit > maxSearchLimit { limit = maxSearchLimit } - res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit) + phoneQuery := "" + if isPhoneSearchQuery(query) { + phoneQuery = normalizePhone(query) + } + res, err := s.users.Search(ctx, userID, query, phoneQuery, limit) if err != nil { return domain.UserSearchResult{}, err } + if s.privacy != nil && phoneQuery != "" && len(res.MyResults)+len(res.Results) > 0 { + targetIDs := make([]int64, 0, len(res.MyResults)+len(res.Results)) + for _, target := range res.MyResults { + if target.ID != 0 && target.ID != userID { + targetIDs = append(targetIDs, target.ID) + } + } + for _, target := range res.Results { + if target.ID != 0 && target.ID != userID { + targetIDs = append(targetIDs, target.ID) + } + } + visibility, err := s.privacy.CanSeeBatch( + ctx, + targetIDs, + userID, + []domain.PrivacyKey{domain.PrivacyKeyAddedByPhone}, + ) + if err != nil { + return domain.UserSearchResult{}, err + } + knownContacts := map[int64]domain.Contact{} + if s.contacts != nil && len(targetIDs) > 0 { + knownContacts, err = s.contacts.GetMany(ctx, userID, targetIDs) + if err != nil { + return domain.UserSearchResult{}, err + } + } + allowed := func(target domain.User) bool { + if visibility[target.ID][domain.PrivacyKeyAddedByPhone] { + return true + } + contact, found := knownContacts[target.ID] + return found && contact.Phone != "" && strings.HasPrefix(contact.Phone, phoneQuery) + } + res.MyResults = filterSearchUsers(res.MyResults, allowed) + res.Results = filterSearchUsers(res.Results, allowed) + } return s.projectSearchResult(ctx, userID, res) } @@ -452,11 +517,14 @@ func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, v if s.contacts == nil { return false, nil } - _, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID) + contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID) if err != nil { return false, err } - return found, nil + // Merely adding the owner by user id does not mean the viewer knows the + // owner's phone. Only a non-empty owner-scoped contact phone can suppress the + // "share my phone" prompt when PhoneNumber privacy itself denies visibility. + return found && contact.Phone != "", nil } // BlockContact adds peer to the current user's blocklist. @@ -506,7 +574,22 @@ func (s *Service) GetBlocked(ctx context.Context, userID int64, offset, limit in if limit <= 0 || limit > 100 { limit = 100 } - return s.contacts.ListBlocked(ctx, userID, offset, limit) + list, err := s.contacts.ListBlocked(ctx, userID, offset, limit) + if err != nil || len(list.Blocked) == 0 || s.projector == nil { + return list, err + } + users := make([]domain.User, len(list.Blocked)) + for i := range list.Blocked { + users[i] = list.Blocked[i].User + } + projected, err := s.projector.ForViewer(ctx, userID, users) + if err != nil { + return domain.BlockedContactList{}, err + } + for i := range list.Blocked { + list.Blocked[i].User = projected[i] + } + return list, nil } func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]int, bool, error) { @@ -590,6 +673,34 @@ func normalizePhone(phone string) string { return phone } +func isPhoneSearchQuery(query string) bool { + query = strings.TrimSpace(query) + if query == "" { + return false + } + hasDigit := false + for _, r := range query { + switch { + case r >= '0' && r <= '9': + hasDigit = true + case r == '+', r == ' ', r == '-', r == '(', r == ')': + default: + return false + } + } + return hasDigit +} + +func filterSearchUsers(users []domain.User, keep func(domain.User) bool) []domain.User { + out := users[:0] + for _, user := range users { + if keep(user) { + out = append(out, user) + } + } + return out +} + func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 { out := make([]int64, 0, len(ids)) seen := make(map[int64]struct{}, len(ids)) diff --git a/internal/app/contacts/service_test.go b/internal/app/contacts/service_test.go index ee7a911a..17c9d705 100644 --- a/internal/app/contacts/service_test.go +++ b/internal/app/contacts/service_test.go @@ -168,6 +168,151 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) { } } +func TestAddContactWithoutPhoneDoesNotBackfillTargetPhone(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore) + svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc)) + + contact, err := svc.AddContact(ctx, owner.ID, domain.ContactInput{ + ContactUserID: target.ID, + FirstName: "Saved", + Phone: "", + }) + if err != nil { + t.Fatalf("AddContact: %v", err) + } + if contact.Phone != "" || contact.User.Phone != "" { + t.Fatalf("projected contact phone = local %q user %q, want both empty", contact.Phone, contact.User.Phone) + } + stored, found, err := contactsStore.Get(ctx, owner.ID, target.ID) + if err != nil || !found { + t.Fatalf("stored contact found=%v err=%v", found, err) + } + if stored.Phone != "" || stored.User.Phone != "" { + t.Fatalf("stored contact phone = local %q user %q, want both empty", stored.Phone, stored.User.Phone) + } +} + +func TestImportContactsHonorsAddedByPhoneInOneBatch(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore) + if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set AddedByPhone: %v", err) + } + svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc)) + input := []domain.ContactInput{{ClientID: 1, Phone: target.Phone, FirstName: "Saved"}} + + hidden, err := svc.ImportContacts(ctx, owner.ID, input) + if err != nil { + t.Fatalf("ImportContacts hidden: %v", err) + } + if len(hidden.Imported) != 0 || len(hidden.Contacts) != 0 { + t.Fatalf("hidden import = %+v, want no resolved target", hidden) + } + + if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{ + ContactUserID: owner.ID, + FirstName: owner.FirstName, + }); err != nil { + t.Fatalf("target add owner: %v", err) + } + visible, err := svc.ImportContacts(ctx, owner.ID, input) + if err != nil { + t.Fatalf("ImportContacts visible: %v", err) + } + if len(visible.Imported) != 1 || visible.Imported[0].UserID != target.ID || len(visible.Contacts) != 1 { + t.Fatalf("visible import = %+v, want target %d", visible, target.ID) + } +} + +func TestPhoneSearchHonorsAddedByPhone(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contactsStore := memory.NewContactStore() + owner, err := users.Create(ctx, domain.User{Phone: "100", FirstName: "Owner"}) + if err != nil { + t.Fatalf("create owner: %v", err) + } + target, err := users.Create(ctx, domain.User{Phone: "15551234567", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + privacySvc := privacyapp.NewService(memory.NewPrivacyStore(), contactsStore) + if _, err := privacySvc.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set AddedByPhone: %v", err) + } + svc := NewService(contactsStore, users).Configure(WithPrivacyEvaluator(privacySvc)) + + hidden, err := svc.Search(ctx, owner.ID, "+1 (555) 123-4567", 50) + if err != nil { + t.Fatalf("Search hidden: %v", err) + } + if len(hidden.Results) != 0 { + t.Fatalf("hidden phone search results = %+v, want empty", hidden.Results) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ + ContactUserID: target.ID, + FirstName: target.FirstName, + Phone: "", + }); err != nil { + t.Fatalf("owner add target without phone: %v", err) + } + stillHidden, err := svc.Search(ctx, owner.ID, target.Phone, 50) + if err != nil { + t.Fatalf("Search owner-only contact: %v", err) + } + if len(stillHidden.MyResults)+len(stillHidden.Results) != 0 { + t.Fatalf("owner-only empty-phone contact search = %+v, want hidden", stillHidden) + } + if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{ + ContactUserID: target.ID, + FirstName: target.FirstName, + Phone: target.Phone, + }); err != nil { + t.Fatalf("owner save target phone: %v", err) + } + knownLocally, err := svc.Search(ctx, owner.ID, target.Phone, 50) + if err != nil { + t.Fatalf("Search locally known phone: %v", err) + } + if len(knownLocally.MyResults)+len(knownLocally.Results) != 1 { + t.Fatalf("locally known phone search = %+v, want one local contact", knownLocally) + } + if _, err := contactsStore.Upsert(ctx, target.ID, domain.ContactInput{ + ContactUserID: owner.ID, + FirstName: owner.FirstName, + }); err != nil { + t.Fatalf("target add owner: %v", err) + } + visible, err := svc.Search(ctx, owner.ID, target.Phone, 50) + if err != nil { + t.Fatalf("Search visible: %v", err) + } + if len(visible.Results) != 1 || visible.Results[0].ID != target.ID { + t.Fatalf("visible phone search = %+v, want target %d", visible.Results, target.ID) + } +} + func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) { ctx := context.Background() users := memory.NewUserStore() @@ -387,8 +532,8 @@ func TestAddContactNormalizesPhoneToDigits(t *testing.T) { if err != nil { t.Fatalf("AddContact digitless phone: %v", err) } - if emptied.Phone != bob.Phone { - t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone) + if emptied.Phone != "" || emptied.User.Phone != "" { + t.Fatalf("digitless phone contact = local %q user %q, want empty without account-phone fallback", emptied.Phone, emptied.User.Phone) } } diff --git a/internal/app/privacy/cache.go b/internal/app/privacy/cache.go index e94d16e7..2fcd7539 100644 --- a/internal/app/privacy/cache.go +++ b/internal/app/privacy/cache.go @@ -81,6 +81,31 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P return err } c.InvalidateOwners(rules.OwnerUserID) + // 数据写入已提交,预热失败不能伪装成写失败;LISTEN/NOTIFY 也会在每个 + // 实例上再次失效并预热,覆盖本实例通知晚于这里到达的时序。 + _ = c.WarmOwners(ctx, rules.OwnerUserID) + return nil +} + +// WarmOwners 在低频写/变更通知路径一次性装入 owner 的完整规则集。调用方必须先 +// InvalidateOwners;epoch 保证预热期间若又发生失效,不会把旧快照写回。 +func (c *CachedPrivacyStore) WarmOwners(ctx context.Context, ownerUserIDs ...int64) error { + owners := dedupPrivacyOwnerIDs(ownerUserIDs) + if len(owners) == 0 || c == nil || c.cache == nil { + return nil + } + + // 隐私规则变更很少、读取极热。必须重建全部 key, + // 不能只塞本次 key,否则会把 owner 的其它持久规则误当成默认规则。 + loadEpoch := c.cache.LoadEpoch() + list, err := c.inner.ListPrivacyRules(ctx, owners, allPrivacyRuleKeys) + if err != nil { + return err + } + snapshots := buildPrivacyRulesByOwner(list, owners) + for _, ownerUserID := range owners { + c.cache.StoreIfEpoch(ownerUserID, snapshots[ownerUserID], loadEpoch) + } return nil } diff --git a/internal/app/privacy/cache_test.go b/internal/app/privacy/cache_test.go index c0851fcf..055ba6a1 100644 --- a/internal/app/privacy/cache_test.go +++ b/internal/app/privacy/cache_test.go @@ -108,7 +108,7 @@ func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) { } } -func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) { +func TestCachedPrivacyStoreWarmsCompleteOwnerSnapshotOnSet(t *testing.T) { ctx := context.Background() base := memory.NewPrivacyStore() counting := &countingPrivacyStore{PrivacyStore: base} @@ -121,24 +121,34 @@ func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) { t.Fatalf("set first: %v", err) } if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok { - t.Fatalf("prime get ok=%v err=%v", ok, err) + t.Fatalf("first memory get ok=%v err=%v", ok, err) } if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{ OwnerUserID: 1001, - Key: domain.PrivacyKeyPhoneNumber, - Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}, + Key: domain.PrivacyKeyProfilePhoto, + Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}, }); err != nil { t.Fatalf("set second: %v", err) } got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber) if err != nil || !ok { - t.Fatalf("after invalidation get ok=%v err=%v", ok, err) + t.Fatalf("phone after second set ok=%v err=%v", ok, err) } - if got.Rules[0].Kind != domain.PrivacyRuleAllowAll { - t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules) + if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("phone rules after second set = %+v, want disallow all", got.Rules) + } + photo, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyProfilePhoto) + if err != nil || !ok { + t.Fatalf("photo after second set ok=%v err=%v", ok, err) + } + if photo.Rules[0].Kind != domain.PrivacyRuleDisallowAll { + t.Fatalf("photo rules after second set = %+v, want disallow all", photo.Rules) + } + if counting.setCalls != 2 { + t.Fatalf("SetPrivacyRules calls = %d, want 2", counting.setCalls) } if counting.listCalls != 2 { - t.Fatalf("ListPrivacyRules calls = %d, want 2 after invalidation", counting.listCalls) + t.Fatalf("ListPrivacyRules calls = %d, want exactly one write-path warm per set and no read-path query", counting.listCalls) } } diff --git a/internal/app/userprojection/contact_cache.go b/internal/app/userprojection/contact_cache.go index 412d66d4..438e9b12 100644 --- a/internal/app/userprojection/contact_cache.go +++ b/internal/app/userprojection/contact_cache.go @@ -1,8 +1,10 @@ package userprojection import ( + "container/list" "context" "fmt" + "sort" "sync" "time" @@ -18,7 +20,7 @@ const ( DefaultContactProjectionCacheTTL = 24 * time.Hour contactSnapshotMaxViewers = 4096 - contactReverseSnapshotOwnerCap = 16 + contactReversePairMaxEntries = 262144 contactPersonalPhotoSnapshotCap = 4096 ) @@ -34,11 +36,32 @@ type personalPhotoSnapshot struct { expireAt time.Time } +type reverseContactKey struct { + ownerUserID int64 + contactUserID int64 +} + +type reverseContactSnapshot struct { + contact domain.Contact + found bool + expireAt time.Time +} + +type reverseContactEntry struct { + key reverseContactKey + snapshot reverseContactSnapshot +} + type contactSnapshotLoadResult struct { snap contactAccountSnapshot stored bool } +type reverseContactLoadResult struct { + contacts map[int64]domain.Contact + stored bool +} + type personalPhotoSnapshotLoadResult struct { snap personalPhotoSnapshot stored bool @@ -59,6 +82,10 @@ type CachedContactStore struct { mu sync.RWMutex contacts map[int64]contactAccountSnapshot personalPhotos map[int64]personalPhotoSnapshot + reverse map[reverseContactKey]*list.Element + reverseLRU *list.List + reverseByOwner map[int64]map[int64]struct{} + reverseCap int epoch uint64 sf singleflight.Group } @@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC now: time.Now, contacts: make(map[int64]contactAccountSnapshot, 1024), personalPhotos: make(map[int64]personalPhotoSnapshot, 1024), + reverse: make(map[reverseContactKey]*list.Element, 4096), + reverseLRU: list.New(), + reverseByOwner: make(map[int64]map[int64]struct{}, 1024), + reverseCap: contactReversePairMaxEntries, } } @@ -131,24 +162,90 @@ func (c *CachedContactStore) GetReverseContacts(ctx context.Context, userID int6 if len(owners) == 0 { return out, nil } - if len(owners) > contactReverseSnapshotOwnerCap { - // Large fan-out should keep using the store's set query until a dedicated - // reverse-contact read model exists; loading hundreds of full contact - // lists would be worse than one batched SQL. - return c.inner.GetReverseContacts(ctx, userID, owners) - } + missing := make([]int64, 0, len(owners)) + now := c.now() for _, ownerID := range owners { - snap, err := c.contactSnapshot(ctx, ownerID) - if err != nil { - return nil, err + // Reuse a full owner snapshot when another hot path already loaded it. + // Do not cold-load one full list per owner: a large projection would turn + // into N SQL queries. + if snap, ok := c.lookupContactSnapshot(ownerID, now); ok { + if contact, found := snap.contacts[userID]; found { + out[ownerID] = cloneCachedContact(contact) + } + continue } - if contact, ok := snap.contacts[userID]; ok { + if contact, found, cached := c.lookupReverseContact(ownerID, userID, now); cached { + if found { + out[ownerID] = contact + } + continue + } + missing = append(missing, ownerID) + } + if len(missing) == 0 { + return out, nil + } + loaded, err := c.loadReverseContacts(ctx, userID, missing) + if err != nil { + return nil, err + } + for ownerID, contact := range loaded { + if contact.User.ID != 0 { out[ownerID] = cloneCachedContact(contact) } } return out, nil } +// loadReverseContacts performs at most one batched cold-store read for all +// missing owner→viewer pairs, then caches both hits and misses. Privacy +// projection therefore stays memory-only after warm-up instead of repeating a +// reverse-contact SQL query on every large user vector. +func (c *CachedContactStore) loadReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) { + owners := append([]int64(nil), ownerUserIDs...) + sort.Slice(owners, func(i, j int) bool { return owners[i] < owners[j] }) + sfKey := fmt.Sprintf("contact-reverse:%d:%v", userID, owners) + for { + v, err, _ := c.sf.Do(sfKey, func() (any, error) { + loadEpoch := c.cacheEpoch() + contacts, err := c.inner.GetReverseContacts(ctx, userID, owners) + if err != nil { + return reverseContactLoadResult{}, err + } + now := c.now() + expireAt := now.Add(c.ttl) + c.mu.Lock() + stored := c.epoch == loadEpoch + if stored { + for _, ownerID := range owners { + key := reverseContactKey{ownerUserID: ownerID, contactUserID: userID} + contact, found := contacts[ownerID] + c.storeReverseContactLocked(key, reverseContactSnapshot{ + contact: cloneCachedContact(contact), + found: found, + expireAt: expireAt, + }) + } + } + c.mu.Unlock() + return reverseContactLoadResult{ + contacts: cloneCachedContactMap(contacts), + stored: stored, + }, nil + }) + if err != nil { + return nil, err + } + result := v.(reverseContactLoadResult) + if result.stored { + return result.contacts, nil + } + if err := ctx.Err(); err != nil { + return nil, err + } + } +} + func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) { contact, err := c.inner.Upsert(ctx, userID, input) if err == nil { @@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time. return snap, true } +func (c *CachedContactStore) lookupReverseContact(ownerUserID, contactUserID int64, now time.Time) (domain.Contact, bool, bool) { + key := reverseContactKey{ownerUserID: ownerUserID, contactUserID: contactUserID} + c.mu.Lock() + element, ok := c.reverse[key] + if !ok { + c.mu.Unlock() + return domain.Contact{}, false, false + } + entry := element.Value.(*reverseContactEntry) + snap := entry.snapshot + if !snap.expireAt.After(now) { + c.removeReverseElementLocked(element) + c.mu.Unlock() + return domain.Contact{}, false, false + } + c.reverseLRU.MoveToFront(element) + c.mu.Unlock() + return cloneCachedContact(snap.contact), snap.found, true +} + +func (c *CachedContactStore) storeReverseContactLocked(key reverseContactKey, snapshot reverseContactSnapshot) { + if element, ok := c.reverse[key]; ok { + entry := element.Value.(*reverseContactEntry) + entry.snapshot = snapshot + c.reverseLRU.MoveToFront(element) + return + } + element := c.reverseLRU.PushFront(&reverseContactEntry{key: key, snapshot: snapshot}) + c.reverse[key] = element + if c.reverseByOwner[key.ownerUserID] == nil { + c.reverseByOwner[key.ownerUserID] = make(map[int64]struct{}) + } + c.reverseByOwner[key.ownerUserID][key.contactUserID] = struct{}{} + for c.reverseLRU.Len() > c.reverseCap { + c.removeReverseElementLocked(c.reverseLRU.Back()) + } +} + +func (c *CachedContactStore) removeReverseElementLocked(element *list.Element) { + if element == nil { + return + } + entry := element.Value.(*reverseContactEntry) + delete(c.reverse, entry.key) + if viewers := c.reverseByOwner[entry.key.ownerUserID]; viewers != nil { + delete(viewers, entry.key.contactUserID) + if len(viewers) == 0 { + delete(c.reverseByOwner, entry.key.ownerUserID) + } + } + c.reverseLRU.Remove(element) +} + func (c *CachedContactStore) InvalidateViewers(ids ...int64) { if c == nil || len(ids) == 0 { return @@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) { } delete(c.contacts, id) delete(c.personalPhotos, id) + for contactUserID := range c.reverseByOwner[id] { + if element, ok := c.reverse[reverseContactKey{ownerUserID: id, contactUserID: contactUserID}]; ok { + c.removeReverseElementLocked(element) + } + } } c.mu.Unlock() } @@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() { c.epoch++ c.contacts = make(map[int64]contactAccountSnapshot, 1024) c.personalPhotos = make(map[int64]personalPhotoSnapshot, 1024) + c.reverse = make(map[reverseContactKey]*list.Element, 4096) + c.reverseLRU.Init() + c.reverseByOwner = make(map[int64]map[int64]struct{}, 1024) c.mu.Unlock() } @@ -415,6 +573,14 @@ func buildContactAccountSnapshot(list domain.ContactList, expireAt time.Time) co return contactAccountSnapshot{contacts: contacts, ordered: ordered, hash: list.Hash, expireAt: expireAt} } +func cloneCachedContactMap(in map[int64]domain.Contact) map[int64]domain.Contact { + out := make(map[int64]domain.Contact, len(in)) + for id, contact := range in { + out[id] = cloneCachedContact(contact) + } + return out +} + func dedupContactIDs(ids []int64) []int64 { seen := make(map[int64]struct{}, len(ids)) out := make([]int64, 0, len(ids)) diff --git a/internal/app/userprojection/contact_cache_test.go b/internal/app/userprojection/contact_cache_test.go index ed88e3f4..8cf02499 100644 --- a/internal/app/userprojection/contact_cache_test.go +++ b/internal/app/userprojection/contact_cache_test.go @@ -162,6 +162,81 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) { } } +func TestCachedContactStoreCachesLargeReverseContactBatch(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + owners := make([]int64, 32) + for i := range owners { + owners[i] = int64(i + 1) + if i%2 == 0 { + if _, err := base.Upsert(ctx, owners[i], domain.ContactInput{ + ContactUserID: 9001, + FirstName: "Viewer", + }); err != nil { + t.Fatalf("seed owner %d: %v", owners[i], err) + } + } + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + + first, err := cached.GetReverseContacts(ctx, 9001, owners) + if err != nil { + t.Fatalf("first reverse lookup: %v", err) + } + if len(first) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 { + t.Fatalf("first reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(first), counting.reverseCalls, counting.listCalls) + } + second, err := cached.GetReverseContacts(ctx, 9001, owners) + if err != nil { + t.Fatalf("second reverse lookup: %v", err) + } + if len(second) != 16 || counting.reverseCalls != 1 || counting.listCalls != 0 { + t.Fatalf("cached reverse hits=%d reverseCalls=%d listCalls=%d, want 16/1/0", len(second), counting.reverseCalls, counting.listCalls) + } + + cached.InvalidateViewers(owners[0]) + third, err := cached.GetReverseContacts(ctx, 9001, owners) + if err != nil { + t.Fatalf("reverse lookup after owner invalidation: %v", err) + } + if len(third) != 16 || counting.reverseCalls != 2 { + t.Fatalf("invalidated reverse hits=%d reverseCalls=%d, want 16/2", len(third), counting.reverseCalls) + } +} + +func TestCachedContactStoreReversePairsUsePerEntryLRU(t *testing.T) { + ctx := context.Background() + base := memory.NewContactStore() + for ownerID := int64(1); ownerID <= 3; ownerID++ { + if _, err := base.Upsert(ctx, ownerID, domain.ContactInput{ + ContactUserID: 9001, + FirstName: "Viewer", + }); err != nil { + t.Fatalf("seed owner %d: %v", ownerID, err) + } + } + counting := &countingContactStore{ContactStore: base} + cached := NewCachedContactStore(counting, 0) + cached.reverseCap = 2 + + for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} { + got, err := cached.GetReverseContacts(ctx, 9001, []int64{ownerID}) + if err != nil { + t.Fatalf("reverse owner %d: %v", ownerID, err) + } + if _, ok := got[ownerID]; !ok { + t.Fatalf("reverse owner %d missing", ownerID) + } + } + if counting.reverseCalls != 4 { + t.Fatalf("reverse calls = %d, want 4 (owner 1 touched, owner 2 evicted only)", counting.reverseCalls) + } + if len(cached.reverse) != 2 || cached.reverseLRU.Len() != 2 { + t.Fatalf("reverse cache map/list = %d/%d, want 2/2", len(cached.reverse), cached.reverseLRU.Len()) + } +} + func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) { ctx := context.Background() base := memory.NewContactStore() diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index 5c654aca..a5c01adf 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -209,7 +209,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users vis = matrix[u.ID][viewer] } var perr error - pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, found, vis, profileRefs, fallbackRefs, nil) + hasKnownContactPhone := found && contact.Phone != "" + pj, perr = applyPrivacy(ctx, p.privacy, viewer, pj, hasKnownContactPhone, vis, profileRefs, fallbackRefs, nil) if perr != nil { return nil, perr } @@ -341,8 +342,9 @@ func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users [ } // ForViewer applies the owner-specific user view that Telegram clients expect. -// In particular, phone is visible for self and contacts; non-contacts should not -// receive a phone field because TDesktop will prefer it over the public name. +// A contact relationship alone never grants phone visibility. A viewer may retain +// an owner-scoped phone it explicitly supplied, while the target account phone is +// governed by PhoneNumber privacy. func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) { users = sanitizeDeletedUsers(users) if contacts == nil || viewerUserID == 0 || len(users) == 0 { @@ -489,8 +491,9 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot { contact, found := contactsByID[u.ID] projected = applyContactProjection(projected, contact, found) + hasKnownContactPhone := found && contact.Phone != "" var err error - projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, visibility[u.ID], profileRefs, fallbackRefs, personalRefs) + projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, hasKnownContactPhone, visibility[u.ID], profileRefs, fallbackRefs, personalRefs) if err != nil { return nil, err } @@ -608,9 +611,11 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend user.ContactNote = contact.Note user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...) - if contact.User.Phone != "" { - user.Phone = contact.User.Phone - } else { + // contact.Phone is an owner-local fact supplied by this viewer. It may differ + // from the target's current account phone and is safe to preserve because the + // viewer already knew it. An empty contact.Phone must not replace or authorize + // the target account phone carried by user.Phone. + if contact.Phone != "" { user.Phone = contact.Phone } if contact.User.FirstName != "" || contact.User.LastName != "" { @@ -623,11 +628,16 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool return user } -func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { +func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, hasKnownContactPhone bool, vis map[domain.PrivacyKey]bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) { if user.Deleted { return user.DeletedTombstone(), nil } if privacy == nil { + // Missing privacy wiring must fail closed for an account phone. The only + // safe exception is an owner-scoped phone the viewer explicitly supplied. + if !hasKnownContactPhone { + user.Phone = "" + } return user, nil } // vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得); @@ -642,7 +652,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in if err != nil { return domain.User{}, err } - if !phoneAllowed && !isContact { + if !phoneAllowed && !hasKnownContactPhone { user.Phone = "" } statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp) diff --git a/internal/app/userprojection/projection_test.go b/internal/app/userprojection/projection_test.go index 6be85d50..e2ae6189 100644 --- a/internal/app/userprojection/projection_test.go +++ b/internal/app/userprojection/projection_test.go @@ -119,6 +119,51 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) { } } +func TestProjectorContactWithoutKnownPhoneCannotBypassPhonePrivacy(t *testing.T) { + ctx := context.Background() + const ( + viewerID = int64(3101) + ownerID = int64(3102) + ) + contacts := memory.NewContactStore() + if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{ + ContactUserID: ownerID, + FirstName: "Saved", + Phone: "", + }); err != nil { + t.Fatalf("upsert contact: %v", err) + } + privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts) + projector := New( + WithContactStore(contacts), + WithPrivacyEvaluator(privacy), + ) + users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ + ID: ownerID, + Phone: "15550003102", + FirstName: "Owner", + }}) + if err != nil { + t.Fatalf("ForViewer: %v", err) + } + owner := projectionUser(t, users, ownerID) + if !owner.Contact || owner.Phone != "" { + t.Fatalf("owner projection = %+v, want contact=true with hidden phone", owner) + } + batch, err := projector.ForViewers(ctx, []int64{viewerID}, []domain.User{{ + ID: ownerID, + Phone: "15550003102", + FirstName: "Owner", + }}) + if err != nil { + t.Fatalf("ForViewers: %v", err) + } + batchOwner := projectionUser(t, batch[viewerID], ownerID) + if !batchOwner.Contact || batchOwner.Phone != "" { + t.Fatalf("batch owner projection = %+v, want contact=true with hidden phone", batchOwner) + } +} + func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) { ctx := context.Background() const ( diff --git a/internal/app/users/service.go b/internal/app/users/service.go index ac76a22b..29bae7d1 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -563,7 +563,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user return u, true, nil } -// ResolvePhone 解析手机号到用户;当前阶段默认允许手机号深链解析,隐私规则后续接 account privacy。 +// ResolvePhone resolves a phone number only when the target's AddedByPhone +// privacy allows the current viewer. The evaluator is backed by owner-level +// privacy/contact snapshots in production, so this adds no per-rule SQL query. func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) { if _, err := s.loadSelf(ctx, currentUserID); err != nil { return domain.User{}, false, err @@ -577,6 +579,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s return u, found, err } s.putCachedUsers(ctx, u) + if s.privacy != nil && u.ID != currentUserID { + allowed := false + var err error + if batch, ok := s.privacy.(userprojection.BatchPrivacyEvaluator); ok { + visibility, batchErr := batch.CanSeeBatch( + ctx, + []int64{u.ID}, + currentUserID, + []domain.PrivacyKey{domain.PrivacyKeyAddedByPhone}, + ) + err = batchErr + allowed = visibility[u.ID][domain.PrivacyKeyAddedByPhone] + } else { + allowed, err = s.privacy.CanSee(ctx, u.ID, currentUserID, domain.PrivacyKeyAddedByPhone) + } + if err != nil { + return domain.User{}, false, err + } + if !allowed { + return domain.User{}, false, domain.ErrPhoneNotOccupied + } + } u, err = s.projectOne(ctx, currentUserID, u) if err != nil { return domain.User{}, false, err diff --git a/internal/app/users/service_test.go b/internal/app/users/service_test.go index 432de6ee..f515a195 100644 --- a/internal/app/users/service_test.go +++ b/internal/app/users/service_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + privacyapp "telesrv/internal/app/privacy" "telesrv/internal/domain" "telesrv/internal/store/memory" ) @@ -60,6 +61,42 @@ func TestServiceUsernameLifecycle(t *testing.T) { } } +func TestResolvePhoneHonorsAddedByPhone(t *testing.T) { + ctx := context.Background() + users := memory.NewUserStore() + contacts := memory.NewContactStore() + viewer, err := users.Create(ctx, domain.User{AccessHash: 1, Phone: "15550001001", FirstName: "Viewer"}) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + target, err := users.Create(ctx, domain.User{AccessHash: 2, Phone: "15550001002", FirstName: "Target"}) + if err != nil { + t.Fatalf("create target: %v", err) + } + privacy := privacyapp.NewService(memory.NewPrivacyStore(), contacts) + if _, err := privacy.SetRules(ctx, target.ID, domain.PrivacyKeyAddedByPhone, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil { + t.Fatalf("set AddedByPhone: %v", err) + } + svc := NewService(users, + WithContactStore(contacts), + WithPrivacyEvaluator(privacy), + ) + + if _, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone); !errors.Is(err, domain.ErrPhoneNotOccupied) || found { + t.Fatalf("ResolvePhone stranger found=%v err=%v, want phone not occupied", found, err) + } + if _, err := contacts.Upsert(ctx, target.ID, domain.ContactInput{ + ContactUserID: viewer.ID, + FirstName: viewer.FirstName, + }); err != nil { + t.Fatalf("target add viewer: %v", err) + } + got, found, err := svc.ResolvePhone(ctx, viewer.ID, target.Phone) + if err != nil || !found || got.ID != target.ID { + t.Fatalf("ResolvePhone contact = %+v found=%v err=%v, want target", got, found, err) + } +} + func TestServiceUpdateProfile(t *testing.T) { ctx := context.Background() store := memory.NewUserStore() diff --git a/internal/rpc/contacts_users_rpc_test.go b/internal/rpc/contacts_users_rpc_test.go index 6c44e580..e37f1dbc 100644 --- a/internal/rpc/contacts_users_rpc_test.go +++ b/internal/rpc/contacts_users_rpc_test.go @@ -1276,7 +1276,7 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{ ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash}, - Phone: bob.Phone, + Phone: "", FirstName: "Bobby", }) if err != nil { @@ -1291,6 +1291,19 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T } else if allowed { t.Fatalf("bob can see alice phone = true, want false before exception") } + withoutExceptionUpdates := withoutException.(*tg.Updates) + for _, item := range withoutExceptionUpdates.Users { + if user, ok := item.(*tg.User); ok && user.ID == bob.ID && user.Phone != "" { + t.Fatalf("contacts.addContact(phone=\"\") leaked bob phone %q in updates", user.Phone) + } + } + storedBob, found, err := contactsStore.Get(ctx, alice.ID, bob.ID) + if err != nil || !found { + t.Fatalf("stored bob contact found=%v err=%v", found, err) + } + if storedBob.Phone != "" || storedBob.User.Phone != "" { + t.Fatalf("stored bob contact phone = local %q user %q, want empty", storedBob.Phone, storedBob.User.Phone) + } withException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{ AddPhonePrivacyException: true, @@ -1538,8 +1551,8 @@ func TestContactsBlockGetBlockedAndUnblockRPC(t *testing.T) { if peer, ok := full.Blocked[0].PeerID.(*tg.PeerUser); !ok || peer.UserID != alice.ID { t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID) } - if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID { - t.Fatalf("blocked user = %#v, want alice", full.Users[0]) + if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" { + t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0]) } ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{ diff --git a/internal/store/memory/contacts.go b/internal/store/memory/contacts.go index cb688ad4..9831588f 100644 --- a/internal/store/memory/contacts.go +++ b/internal/store/memory/contacts.go @@ -138,9 +138,6 @@ func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.Cont contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil contact.CloseFriend = existing.CloseFriend contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend - if contact.Phone == "" { - contact.User.Phone = existing.User.Phone - } if contact.FirstName == "" { contact.User.FirstName = existing.User.FirstName } diff --git a/internal/store/memory/story.go b/internal/store/memory/story.go index 945b3450..86c6e7fc 100644 --- a/internal/store/memory/story.go +++ b/internal/store/memory/story.go @@ -1597,7 +1597,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User, profile.LastName, strings.TrimSpace(profile.FirstName + " " + profile.LastName), profile.Username, - profile.Phone, strconv.FormatInt(viewerID, 10), } if isContact { @@ -1610,7 +1609,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User, contact.User.LastName, strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName), contact.User.Username, - contact.User.Phone, ) } for _, candidate := range candidates { diff --git a/internal/store/memory/story_test.go b/internal/store/memory/story_test.go index 705cfa95..b0ea52fc 100644 --- a/internal/store/memory/story_test.go +++ b/internal/store/memory/story_test.go @@ -1627,6 +1627,33 @@ func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) { t.Fatalf("username query = %+v, want viewer 2002", stranger) } + hiddenAccountPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + Query: "155502", + }) + if err != nil { + t.Fatalf("list query hidden account phone: %v", err) + } + if hiddenAccountPhone.Count != 0 || len(hiddenAccountPhone.Views) != 0 { + t.Fatalf("hidden account phone query = %+v, want no match", hiddenAccountPhone) + } + knownContactPhone, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ + ViewerUserID: owner.ID, + Owner: owner, + StoryID: 1, + Limit: 10, + Query: "7001", + }) + if err != nil { + t.Fatalf("list query known contact phone: %v", err) + } + if knownContactPhone.Count != 1 || len(knownContactPhone.Views) != 1 || knownContactPhone.Views[0].ViewerID != 2001 { + t.Fatalf("known contact phone query = %+v, want viewer 2001", knownContactPhone) + } + intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{ ViewerUserID: owner.ID, Owner: owner, diff --git a/internal/store/postgres/channel_member_cache_test.go b/internal/store/postgres/channel_member_cache_test.go index 80f1de8a..d0d0fcb3 100644 --- a/internal/store/postgres/channel_member_cache_test.go +++ b/internal/store/postgres/channel_member_cache_test.go @@ -419,6 +419,7 @@ func (f *fakeDialogReadModelCache) flushCount() int { type fakePrivacyReadModelCache struct { mu sync.Mutex ids []int64 + warmed []int64 flushes int } @@ -428,6 +429,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) { f.ids = append(f.ids, ids...) } +func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.warmed = append(f.warmed, ids...) + return nil +} + func (f *fakePrivacyReadModelCache) FlushReadModelCache() { f.mu.Lock() defer f.mu.Unlock() @@ -440,6 +448,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 { return append([]int64(nil), f.ids...) } +func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.warmed...) +} + func (f *fakePrivacyReadModelCache) flushCount() int { f.mu.Lock() defer f.mu.Unlock() @@ -509,6 +523,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) { if len(privacy.ids) != 1 || privacy.ids[0] != 21 { t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids) } + if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 { + t.Fatalf("privacy warms = %v, want [21]", warmed) + } listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`) if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) { diff --git a/internal/store/postgres/read_model_listener.go b/internal/store/postgres/read_model_listener.go index 0a4b0cbf..b5155815 100644 --- a/internal/store/postgres/read_model_listener.go +++ b/internal/store/postgres/read_model_listener.go @@ -3,6 +3,7 @@ package postgres import ( "context" "encoding/json" + "time" "github.com/jackc/pgx/v5" "go.uber.org/zap" @@ -13,6 +14,8 @@ import ( const readModelChangeNotifyChannel = "telesrv_read_model_changed" +const privacyReadModelWarmTimeout = 5 * time.Second + // ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。 // 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。 type ReadModelCacheSet struct { @@ -90,6 +93,14 @@ type PrivacyReadModelCache interface { FlushReadModelCache() } +// PrivacyReadModelWarmer lets the low-frequency change stream rebuild owner +// snapshots after invalidation, so the next user projection does not own a +// synchronous database miss. It is optional; caches without it remain +// cache-aside and only receive invalidation. +type PrivacyReadModelWarmer interface { + WarmOwners(context.Context, ...int64) error +} + type ProfilePhotoReadModelCache interface { InvalidateOwner(domain.PeerType, int64) FlushReadModelCache() @@ -393,6 +404,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) { l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID) l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(evt.OwnerUserID) } + if warmer, ok := l.caches.Privacy.(PrivacyReadModelWarmer); ok && evt.OwnerUserID != 0 { + ctx, cancel := context.WithTimeout(context.Background(), privacyReadModelWarmTimeout) + err := warmer.WarmOwners(ctx, evt.OwnerUserID) + cancel() + if err != nil { + l.log.Warn("warm privacy read model after change", + zap.Int64("owner_user_id", evt.OwnerUserID), zap.Error(err)) + } + } case "dialog_light": if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 { peer := domain.Peer{Type: peerType, ID: evt.PeerID} diff --git a/internal/store/postgres/story.go b/internal/store/postgres/story.go index 08e2edbc..00b13b96 100644 --- a/internal/store/postgres/story.go +++ b/internal/store/postgres/story.go @@ -1126,7 +1126,7 @@ WHERE sv.owner_peer_type = $1 OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\' OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\' OR lower(u.username) LIKE $7 ESCAPE '\' - OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\' + OR lower(c.contact_phone) LIKE $7 ESCAPE '\' )`, string(req.Owner.Type), req.Owner.ID, int32(req.StoryID), req.ViewerUserID, req.JustContacts, querySet, queryLike).Scan(&count); err != nil { return domain.StoryViewList{}, fmt.Errorf("count story views: %w", err) } @@ -1160,7 +1160,7 @@ WHERE sv.owner_peer_type = $1 OR lower(COALESCE(c.contact_last_name, u.last_name)) LIKE $7 ESCAPE '\' OR lower(trim(COALESCE(NULLIF(c.contact_first_name, ''), u.first_name) || ' ' || COALESCE(c.contact_last_name, u.last_name))) LIKE $7 ESCAPE '\' OR lower(u.username) LIKE $7 ESCAPE '\' - OR lower(COALESCE(NULLIF(c.contact_phone, ''), u.phone)) LIKE $7 ESCAPE '\' + OR lower(c.contact_phone) LIKE $7 ESCAPE '\' ) AND ( NOT $9::boolean