fix: sync contact phone privacy disclosure
This commit is contained in:
parent
0e2fcdf9c8
commit
e1a95c7318
19 changed files with 789 additions and 50 deletions
|
|
@ -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;
|
||||||
15
deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql
Normal file
15
deploy/migrations/0142_clear_ambiguous_contact_phones.up.sql
Normal 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;
|
||||||
|
|
@ -22,6 +22,7 @@ const maxCloseFriendsCount = 5000
|
||||||
|
|
||||||
type phonePrivacyService interface {
|
type phonePrivacyService interface {
|
||||||
userprojection.PrivacyEvaluator
|
userprojection.PrivacyEvaluator
|
||||||
|
userprojection.BatchPrivacyEvaluator
|
||||||
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
|
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
|
return domain.Contact{}, ErrContactNameEmpty
|
||||||
}
|
}
|
||||||
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
|
// Android 的 contacts.addContact 会提交带 "+" 前缀的号码(TDesktop 传纯数字或空),
|
||||||
// 归一成纯数字;无数字时落空串,走下方 target.Phone 回填。
|
// 归一成纯数字。空串表示客户端只按 user id 添加联系人,必须原样保留;
|
||||||
|
// TL 明确允许省略号码,服务端不得从 target 全局资料反向补出隐私号码。
|
||||||
input.Phone = digitsOnly(input.Phone)
|
input.Phone = digitsOnly(input.Phone)
|
||||||
if s.users != nil {
|
if s.users != nil {
|
||||||
target, found, err := s.users.ByID(ctx, input.ContactUserID)
|
_, found, err := s.users.ByID(ctx, input.ContactUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Contact{}, err
|
return domain.Contact{}, err
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
return domain.Contact{}, ErrContactIDInvalid
|
return domain.Contact{}, ErrContactIDInvalid
|
||||||
}
|
}
|
||||||
if input.Phone == "" {
|
|
||||||
input.Phone = target.Phone
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
contact, err := s.contacts.Upsert(ctx, userID, input)
|
contact, err := s.contacts.Upsert(ctx, userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -235,6 +234,30 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.ImportContactsResult{}, err
|
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))
|
byPhone := make(map[string]domain.User, len(targets))
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
if target.Phone != "" {
|
if target.Phone != "" {
|
||||||
|
|
@ -310,10 +333,52 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
|
||||||
if limit <= 0 || limit > maxSearchLimit {
|
if limit <= 0 || limit > maxSearchLimit {
|
||||||
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 {
|
if err != nil {
|
||||||
return domain.UserSearchResult{}, err
|
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)
|
return s.projectSearchResult(ctx, userID, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -452,11 +517,14 @@ func (s *Service) peerCanSeeCurrentUserPhone(ctx context.Context, ownerUserID, v
|
||||||
if s.contacts == nil {
|
if s.contacts == nil {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
_, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
contact, found, err := s.contacts.Get(ctx, viewerUserID, ownerUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
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.
|
// 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 {
|
if limit <= 0 || limit > 100 {
|
||||||
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) {
|
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
|
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 {
|
func normalizeCloseFriendIDs(userID int64, ids []int64) []int64 {
|
||||||
out := make([]int64, 0, len(ids))
|
out := make([]int64, 0, len(ids))
|
||||||
seen := make(map[int64]struct{}, len(ids))
|
seen := make(map[int64]struct{}, len(ids))
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestGetContactsProjectsCurrentProfilePhoto(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
users := memory.NewUserStore()
|
users := memory.NewUserStore()
|
||||||
|
|
@ -387,8 +532,8 @@ func TestAddContactNormalizesPhoneToDigits(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("AddContact digitless phone: %v", err)
|
t.Fatalf("AddContact digitless phone: %v", err)
|
||||||
}
|
}
|
||||||
if emptied.Phone != bob.Phone {
|
if emptied.Phone != "" || emptied.User.Phone != "" {
|
||||||
t.Fatalf("digitless phone contact = %q, want fallback to target phone %q", emptied.Phone, bob.Phone)
|
t.Fatalf("digitless phone contact = local %q user %q, want empty without account-phone fallback", emptied.Phone, emptied.User.Phone)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,31 @@ func (c *CachedPrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.P
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.InvalidateOwners(rules.OwnerUserID)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@ func TestCachedPrivacyStoreUsesOwnerSnapshot(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
func TestCachedPrivacyStoreWarmsCompleteOwnerSnapshotOnSet(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
base := memory.NewPrivacyStore()
|
base := memory.NewPrivacyStore()
|
||||||
counting := &countingPrivacyStore{PrivacyStore: base}
|
counting := &countingPrivacyStore{PrivacyStore: base}
|
||||||
|
|
@ -121,24 +121,34 @@ func TestCachedPrivacyStoreInvalidatesOnSet(t *testing.T) {
|
||||||
t.Fatalf("set first: %v", err)
|
t.Fatalf("set first: %v", err)
|
||||||
}
|
}
|
||||||
if _, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber); err != nil || !ok {
|
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{
|
if err := cached.SetPrivacyRules(ctx, domain.PrivacyRules{
|
||||||
OwnerUserID: 1001,
|
OwnerUserID: 1001,
|
||||||
Key: domain.PrivacyKeyPhoneNumber,
|
Key: domain.PrivacyKeyProfilePhoto,
|
||||||
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}},
|
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("set second: %v", err)
|
t.Fatalf("set second: %v", err)
|
||||||
}
|
}
|
||||||
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
got, ok, err := cached.GetPrivacyRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||||
if err != nil || !ok {
|
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 {
|
if got.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||||
t.Fatalf("rules after invalidation = %+v, want allow all", got.Rules)
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package userprojection
|
package userprojection
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/list"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -18,7 +20,7 @@ const (
|
||||||
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
DefaultContactProjectionCacheTTL = 24 * time.Hour
|
||||||
|
|
||||||
contactSnapshotMaxViewers = 4096
|
contactSnapshotMaxViewers = 4096
|
||||||
contactReverseSnapshotOwnerCap = 16
|
contactReversePairMaxEntries = 262144
|
||||||
contactPersonalPhotoSnapshotCap = 4096
|
contactPersonalPhotoSnapshotCap = 4096
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,11 +36,32 @@ type personalPhotoSnapshot struct {
|
||||||
expireAt time.Time
|
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 {
|
type contactSnapshotLoadResult struct {
|
||||||
snap contactAccountSnapshot
|
snap contactAccountSnapshot
|
||||||
stored bool
|
stored bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type reverseContactLoadResult struct {
|
||||||
|
contacts map[int64]domain.Contact
|
||||||
|
stored bool
|
||||||
|
}
|
||||||
|
|
||||||
type personalPhotoSnapshotLoadResult struct {
|
type personalPhotoSnapshotLoadResult struct {
|
||||||
snap personalPhotoSnapshot
|
snap personalPhotoSnapshot
|
||||||
stored bool
|
stored bool
|
||||||
|
|
@ -59,6 +82,10 @@ type CachedContactStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
contacts map[int64]contactAccountSnapshot
|
contacts map[int64]contactAccountSnapshot
|
||||||
personalPhotos map[int64]personalPhotoSnapshot
|
personalPhotos map[int64]personalPhotoSnapshot
|
||||||
|
reverse map[reverseContactKey]*list.Element
|
||||||
|
reverseLRU *list.List
|
||||||
|
reverseByOwner map[int64]map[int64]struct{}
|
||||||
|
reverseCap int
|
||||||
epoch uint64
|
epoch uint64
|
||||||
sf singleflight.Group
|
sf singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
@ -76,6 +103,10 @@ func NewCachedContactStore(inner store.ContactStore, ttl time.Duration) *CachedC
|
||||||
now: time.Now,
|
now: time.Now,
|
||||||
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
contacts: make(map[int64]contactAccountSnapshot, 1024),
|
||||||
personalPhotos: make(map[int64]personalPhotoSnapshot, 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 {
|
if len(owners) == 0 {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
if len(owners) > contactReverseSnapshotOwnerCap {
|
missing := make([]int64, 0, len(owners))
|
||||||
// Large fan-out should keep using the store's set query until a dedicated
|
now := c.now()
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
for _, ownerID := range owners {
|
for _, ownerID := range owners {
|
||||||
snap, err := c.contactSnapshot(ctx, ownerID)
|
// Reuse a full owner snapshot when another hot path already loaded it.
|
||||||
if err != nil {
|
// Do not cold-load one full list per owner: a large projection would turn
|
||||||
return nil, err
|
// 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)
|
out[ownerID] = cloneCachedContact(contact)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
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) {
|
func (c *CachedContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||||
contact, err := c.inner.Upsert(ctx, userID, input)
|
contact, err := c.inner.Upsert(ctx, userID, input)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -367,6 +464,59 @@ func (c *CachedContactStore) lookupPersonalPhotoSnapshot(userID int64, now time.
|
||||||
return snap, true
|
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) {
|
func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||||
if c == nil || len(ids) == 0 {
|
if c == nil || len(ids) == 0 {
|
||||||
return
|
return
|
||||||
|
|
@ -379,6 +529,11 @@ func (c *CachedContactStore) InvalidateViewers(ids ...int64) {
|
||||||
}
|
}
|
||||||
delete(c.contacts, id)
|
delete(c.contacts, id)
|
||||||
delete(c.personalPhotos, 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()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -391,6 +546,9 @@ func (c *CachedContactStore) FlushReadModelCache() {
|
||||||
c.epoch++
|
c.epoch++
|
||||||
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
c.contacts = make(map[int64]contactAccountSnapshot, 1024)
|
||||||
c.personalPhotos = make(map[int64]personalPhotoSnapshot, 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()
|
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}
|
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 {
|
func dedupContactIDs(ids []int64) []int64 {
|
||||||
seen := make(map[int64]struct{}, len(ids))
|
seen := make(map[int64]struct{}, len(ids))
|
||||||
out := make([]int64, 0, len(ids))
|
out := make([]int64, 0, len(ids))
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCachedContactStoreInvalidatesAccountSnapshot(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
base := memory.NewContactStore()
|
base := memory.NewContactStore()
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,8 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
||||||
vis = matrix[u.ID][viewer]
|
vis = matrix[u.ID][viewer]
|
||||||
}
|
}
|
||||||
var perr error
|
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 {
|
if perr != nil {
|
||||||
return nil, perr
|
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.
|
// ForViewer applies the owner-specific user view that Telegram clients expect.
|
||||||
// In particular, phone is visible for self and contacts; non-contacts should not
|
// A contact relationship alone never grants phone visibility. A viewer may retain
|
||||||
// receive a phone field because TDesktop will prefer it over the public name.
|
// 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) {
|
func ForViewer(ctx context.Context, contacts store.ContactStore, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||||
users = sanitizeDeletedUsers(users)
|
users = sanitizeDeletedUsers(users)
|
||||||
if contacts == nil || viewerUserID == 0 || len(users) == 0 {
|
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 {
|
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID && !u.Bot {
|
||||||
contact, found := contactsByID[u.ID]
|
contact, found := contactsByID[u.ID]
|
||||||
projected = applyContactProjection(projected, contact, found)
|
projected = applyContactProjection(projected, contact, found)
|
||||||
|
hasKnownContactPhone := found && contact.Phone != ""
|
||||||
var err error
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
|
||||||
user.ContactNote = contact.Note
|
user.ContactNote = contact.Note
|
||||||
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
|
||||||
if contact.User.Phone != "" {
|
// contact.Phone is an owner-local fact supplied by this viewer. It may differ
|
||||||
user.Phone = contact.User.Phone
|
// from the target's current account phone and is safe to preserve because the
|
||||||
} else {
|
// 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
|
user.Phone = contact.Phone
|
||||||
}
|
}
|
||||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||||
|
|
@ -623,11 +628,16 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
|
||||||
return user
|
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 {
|
if user.Deleted {
|
||||||
return user.DeletedTombstone(), nil
|
return user.DeletedTombstone(), nil
|
||||||
}
|
}
|
||||||
if privacy == 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
|
return user, nil
|
||||||
}
|
}
|
||||||
// vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
|
// vis 为批量预取结果(projectBatch 一次 ListPrivacyRules+GetReverseContacts 算得);
|
||||||
|
|
@ -642,7 +652,7 @@ func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID in
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, err
|
return domain.User{}, err
|
||||||
}
|
}
|
||||||
if !phoneAllowed && !isContact {
|
if !phoneAllowed && !hasKnownContactPhone {
|
||||||
user.Phone = ""
|
user.Phone = ""
|
||||||
}
|
}
|
||||||
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
|
statusAllowed, err := canSee(domain.PrivacyKeyStatusTimestamp)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -563,7 +563,9 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
||||||
return u, true, nil
|
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) {
|
func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone string) (domain.User, bool, error) {
|
||||||
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
if _, err := s.loadSelf(ctx, currentUserID); err != nil {
|
||||||
return domain.User{}, false, err
|
return domain.User{}, false, err
|
||||||
|
|
@ -577,6 +579,28 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
||||||
return u, found, err
|
return u, found, err
|
||||||
}
|
}
|
||||||
s.putCachedUsers(ctx, u)
|
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)
|
u, err = s.projectOne(ctx, currentUserID, u)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.User{}, false, err
|
return domain.User{}, false, err
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
privacyapp "telesrv/internal/app/privacy"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
"telesrv/internal/store/memory"
|
"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) {
|
func TestServiceUpdateProfile(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
store := memory.NewUserStore()
|
store := memory.NewUserStore()
|
||||||
|
|
|
||||||
|
|
@ -1276,7 +1276,7 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
|
||||||
|
|
||||||
withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
withoutException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
||||||
ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
|
ID: &tg.InputUser{UserID: bob.ID, AccessHash: bob.AccessHash},
|
||||||
Phone: bob.Phone,
|
Phone: "",
|
||||||
FirstName: "Bobby",
|
FirstName: "Bobby",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -1291,6 +1291,19 @@ func TestContactsAddContactPhonePrivacyExceptionUpdatesPeerSettings(t *testing.T
|
||||||
} else if allowed {
|
} else if allowed {
|
||||||
t.Fatalf("bob can see alice phone = true, want false before exception")
|
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{
|
withException, err := r.onContactsAddContact(WithUserID(ctx, alice.ID), &tg.ContactsAddContactRequest{
|
||||||
AddPhonePrivacyException: true,
|
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 {
|
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)
|
t.Fatalf("blocked peer = %#v, want alice", full.Blocked[0].PeerID)
|
||||||
}
|
}
|
||||||
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID {
|
if user, ok := full.Users[0].(*tg.User); !ok || user.ID != alice.ID || user.Phone != "" {
|
||||||
t.Fatalf("blocked user = %#v, want alice", full.Users[0])
|
t.Fatalf("blocked user = %#v, want alice with hidden phone", full.Users[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{
|
ok, err = r.onContactsUnblock(WithUserID(ctx, bob.ID), &tg.ContactsUnblockRequest{
|
||||||
|
|
|
||||||
|
|
@ -138,9 +138,6 @@ func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.Cont
|
||||||
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
|
contact.User.EmojiStatusUntil = existing.User.EmojiStatusUntil
|
||||||
contact.CloseFriend = existing.CloseFriend
|
contact.CloseFriend = existing.CloseFriend
|
||||||
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
|
contact.User.CloseFriend = existing.CloseFriend || existing.User.CloseFriend
|
||||||
if contact.Phone == "" {
|
|
||||||
contact.User.Phone = existing.User.Phone
|
|
||||||
}
|
|
||||||
if contact.FirstName == "" {
|
if contact.FirstName == "" {
|
||||||
contact.User.FirstName = existing.User.FirstName
|
contact.User.FirstName = existing.User.FirstName
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1597,7 +1597,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
|
||||||
profile.LastName,
|
profile.LastName,
|
||||||
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
|
strings.TrimSpace(profile.FirstName + " " + profile.LastName),
|
||||||
profile.Username,
|
profile.Username,
|
||||||
profile.Phone,
|
|
||||||
strconv.FormatInt(viewerID, 10),
|
strconv.FormatInt(viewerID, 10),
|
||||||
}
|
}
|
||||||
if isContact {
|
if isContact {
|
||||||
|
|
@ -1610,7 +1609,6 @@ func storyViewerMatchesQuery(viewerID int64, query string, profile domain.User,
|
||||||
contact.User.LastName,
|
contact.User.LastName,
|
||||||
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
|
strings.TrimSpace(contact.User.FirstName+" "+contact.User.LastName),
|
||||||
contact.User.Username,
|
contact.User.Username,
|
||||||
contact.User.Phone,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
|
|
|
||||||
|
|
@ -1627,6 +1627,33 @@ func TestStoryStoreListStoryViewsFiltersByContactsAndQuery(t *testing.T) {
|
||||||
t.Fatalf("username query = %+v, want viewer 2002", stranger)
|
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{
|
intersection, err := store.ListStoryViews(ctx, domain.StoryViewListRequest{
|
||||||
ViewerUserID: owner.ID,
|
ViewerUserID: owner.ID,
|
||||||
Owner: owner,
|
Owner: owner,
|
||||||
|
|
|
||||||
|
|
@ -419,6 +419,7 @@ func (f *fakeDialogReadModelCache) flushCount() int {
|
||||||
type fakePrivacyReadModelCache struct {
|
type fakePrivacyReadModelCache struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
ids []int64
|
ids []int64
|
||||||
|
warmed []int64
|
||||||
flushes int
|
flushes int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -428,6 +429,13 @@ func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
|
||||||
f.ids = append(f.ids, ids...)
|
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() {
|
func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
@ -440,6 +448,12 @@ func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
|
||||||
return append([]int64(nil), f.ids...)
|
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 {
|
func (f *fakePrivacyReadModelCache) flushCount() int {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
|
|
@ -509,6 +523,9 @@ func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
|
||||||
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
|
if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
|
||||||
t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
|
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}`)
|
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}) {
|
if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package postgres
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
@ -13,6 +14,8 @@ import (
|
||||||
|
|
||||||
const readModelChangeNotifyChannel = "telesrv_read_model_changed"
|
const readModelChangeNotifyChannel = "telesrv_read_model_changed"
|
||||||
|
|
||||||
|
const privacyReadModelWarmTimeout = 5 * time.Second
|
||||||
|
|
||||||
// ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。
|
// ReadModelCacheSet 是 read_model_versions 通知可失效的进程内投影缓存集合。
|
||||||
// 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。
|
// 后续新增 read model 时,把缓存接到这里即可复用同一条 LISTEN 连接。
|
||||||
type ReadModelCacheSet struct {
|
type ReadModelCacheSet struct {
|
||||||
|
|
@ -90,6 +93,14 @@ type PrivacyReadModelCache interface {
|
||||||
FlushReadModelCache()
|
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 {
|
type ProfilePhotoReadModelCache interface {
|
||||||
InvalidateOwner(domain.PeerType, int64)
|
InvalidateOwner(domain.PeerType, int64)
|
||||||
FlushReadModelCache()
|
FlushReadModelCache()
|
||||||
|
|
@ -393,6 +404,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) {
|
||||||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
|
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.OwnerUserID)
|
||||||
l.caches.RPCProjections.InvalidateRPCProjectionReadModelForViewer(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":
|
case "dialog_light":
|
||||||
if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 {
|
if peerType, ok := readModelPeerType(evt.PeerType); ok && evt.OwnerUserID != 0 && evt.PeerID != 0 {
|
||||||
peer := domain.Peer{Type: peerType, ID: evt.PeerID}
|
peer := domain.Peer{Type: peerType, ID: evt.PeerID}
|
||||||
|
|
|
||||||
|
|
@ -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(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(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(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 {
|
)`, 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)
|
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(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(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(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 (
|
AND (
|
||||||
NOT $9::boolean
|
NOT $9::boolean
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue