fix: sync contact phone privacy disclosure

This commit is contained in:
iamxvbaba 2026-07-24 11:56:58 +08:00
parent 0e2fcdf9c8
commit e1a95c7318
19 changed files with 789 additions and 50 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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))

View file

@ -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)
}
}

View file

@ -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 的完整规则集。调用方必须先
// InvalidateOwnersepoch 保证预热期间若又发生失效,不会把旧快照写回。
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
}

View file

@ -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)
}
}

View file

@ -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))

View file

@ -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()

View file

@ -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)

View file

@ -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 (

View file

@ -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

View file

@ -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()

View file

@ -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{

View file

@ -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
}

View file

@ -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 {

View file

@ -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,

View file

@ -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}) {

View file

@ -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}

View file

@ -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