business: add privacy-aware user projection

(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
This commit is contained in:
A 2026-06-08 01:07:21 +08:00
parent 14d220c971
commit 8ff3343ae0
29 changed files with 2239 additions and 112 deletions

View file

@ -6,6 +6,7 @@ import (
"strings"
"unicode/utf8"
"telesrv/internal/app/userprojection"
"telesrv/internal/domain"
"telesrv/internal/store"
)
@ -18,10 +19,31 @@ var (
const maxSearchLimit = 50
type phonePrivacyService interface {
userprojection.PrivacyEvaluator
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
}
// Service 提供通讯录查询。
type Service struct {
contacts store.ContactStore
users store.UserStore
contacts store.ContactStore
users store.UserStore
photos userprojection.ProfilePhotoProvider
privacy phonePrivacyService
projector *userprojection.Projector
}
// Option adjusts optional contacts service dependencies.
type Option func(*Service)
// WithPhotoProvider enables current profile photo enrichment for returned users.
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
return func(s *Service) { s.photos = p }
}
// WithPrivacyEvaluator enables viewer-specific privacy projection.
func WithPrivacyEvaluator(p phonePrivacyService) Option {
return func(s *Service) { s.privacy = p }
}
// NewService 创建 contacts 服务。
@ -30,9 +52,33 @@ func NewService(contacts store.ContactStore, users ...store.UserStore) *Service
if len(users) > 0 {
s.users = users[0]
}
s.rebuildProjector()
return s
}
// Configure applies optional dependencies after construction.
func (s *Service) Configure(opts ...Option) *Service {
if s == nil {
return s
}
for _, opt := range opts {
opt(s)
}
s.rebuildProjector()
return s
}
func (s *Service) rebuildProjector() {
if s == nil {
return
}
s.projector = userprojection.New(
userprojection.WithContactStore(s.contacts),
userprojection.WithPhotoProvider(s.photos),
userprojection.WithPrivacyEvaluator(s.privacy),
)
}
// GetContacts 返回当前登录账号的通讯录。未登录或无持久化实现时按空账号处理。
func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (domain.ContactList, bool, error) {
if s == nil || s.contacts == nil || userID == 0 {
@ -47,6 +93,9 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
return domain.ContactList{}, false, err
}
}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
return domain.ContactList{}, false, err
}
if hash != 0 && hash == list.Hash {
return list, true, nil
}
@ -110,7 +159,12 @@ func (s *Service) AddContact(ctx context.Context, userID int64, input domain.Con
if err != nil {
return domain.Contact{}, err
}
return contact, nil
if input.AddPhonePrivacyException && s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil {
return domain.Contact{}, err
}
}
return s.projectContact(ctx, userID, contact)
}
// AcceptContact shares the current user's phone/profile with an existing one-way contact.
@ -151,6 +205,11 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
if err != nil {
return domain.Contact{}, err
}
if s.privacy != nil {
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, contactUserID); err != nil {
return domain.Contact{}, err
}
}
contact, found, err := s.contacts.Get(ctx, userID, target.ID)
if err != nil {
return domain.Contact{}, err
@ -158,7 +217,7 @@ func (s *Service) AcceptContact(ctx context.Context, userID, contactUserID int64
if !found {
return domain.Contact{}, ErrContactReqMissing
}
return contact, nil
return s.projectContact(ctx, userID, contact)
}
func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []domain.ContactInput) (domain.ImportContactsResult, error) {
@ -229,7 +288,22 @@ func (s *Service) ImportContacts(ctx context.Context, userID int64, inputs []dom
if err != nil {
return domain.ImportContactsResult{}, err
}
if s.privacy != nil {
for _, input := range upserts {
if !input.AddPhonePrivacyException || input.ContactUserID == 0 {
continue
}
if _, _, err := s.privacy.AddAllowUser(ctx, userID, domain.PrivacyKeyPhoneNumber, input.ContactUserID); err != nil {
return domain.ImportContactsResult{}, err
}
}
}
out.Contacts = append(out.Contacts, contacts...)
projected := domain.ContactList{Contacts: out.Contacts}
if err := s.projectContactUsers(ctx, userID, &projected); err != nil {
return domain.ImportContactsResult{}, err
}
out.Contacts = projected.Contacts
return out, nil
}
@ -246,7 +320,11 @@ func (s *Service) Search(ctx context.Context, userID int64, query string, limit
if limit <= 0 || limit > maxSearchLimit {
limit = maxSearchLimit
}
return s.users.Search(ctx, userID, query, normalizePhone(query), limit)
res, err := s.users.Search(ctx, userID, query, normalizePhone(query), limit)
if err != nil {
return domain.UserSearchResult{}, err
}
return s.projectSearchResult(ctx, userID, res)
}
func (s *Service) DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
@ -270,6 +348,41 @@ func (s *Service) UpdateContactNote(ctx context.Context, userID, contactUserID i
return contact, nil
}
func (s *Service) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error) {
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID || photo.ID == 0 {
return domain.Contact{}, ErrContactIDInvalid
}
contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, photo.ID, date)
if err != nil {
return domain.Contact{}, err
}
if !found {
return domain.Contact{}, ErrContactReqMissing
}
return s.projectContact(ctx, userID, contact)
}
func (s *Service) ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error) {
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 || contactUserID == userID {
return domain.Contact{}, ErrContactIDInvalid
}
contact, found, err := s.contacts.SetPersonalPhoto(ctx, userID, contactUserID, 0, date)
if err != nil {
return domain.Contact{}, err
}
if !found {
return domain.Contact{}, ErrContactReqMissing
}
return s.projectContact(ctx, userID, contact)
}
func (s *Service) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
if s == nil || s.contacts == nil || userID == 0 || len(contactUserIDs) == 0 {
return map[int64]domain.ProfilePhotoRef{}, nil
}
return s.contacts.PersonalPhotos(ctx, userID, contactUserIDs)
}
func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error) {
if s == nil || s.contacts == nil || userID == 0 || peer.Type != domain.PeerTypeUser || peer.ID == 0 || peer.ID == userID {
return domain.PeerSettings{}, nil
@ -282,10 +395,18 @@ func (s *Service) GetPeerSettings(ctx context.Context, userID int64, peer domain
if err != nil {
return domain.PeerSettings{}, err
}
shareContact := found && !contact.Mutual
if s.privacy != nil {
peerCanSeePhone, err := s.privacy.CanSee(ctx, userID, peer.ID, domain.PrivacyKeyPhoneNumber)
if err != nil {
return domain.PeerSettings{}, err
}
shareContact = found && !peerCanSeePhone
}
return domain.PeerSettings{
AddContact: !found,
BlockContact: !blocked,
ShareContact: found && !contact.Mutual,
ShareContact: shareContact,
}, nil
}
@ -343,6 +464,51 @@ func (s *Service) ContactIDs(ctx context.Context, userID int64, hash int64) ([]i
return ids, false, nil
}
func (s *Service) projectContactUsers(ctx context.Context, userID int64, list *domain.ContactList) error {
if s == nil || s.projector == nil || list == nil || len(list.Contacts) == 0 {
return nil
}
users := make([]domain.User, len(list.Contacts))
for i, contact := range list.Contacts {
users[i] = contact.User
}
projected, err := s.projector.ForViewer(ctx, userID, users)
if err != nil {
return err
}
for i := range list.Contacts {
list.Contacts[i].User = projected[i]
}
return nil
}
func (s *Service) projectContact(ctx context.Context, userID int64, contact domain.Contact) (domain.Contact, error) {
list := domain.ContactList{Contacts: []domain.Contact{contact}}
if err := s.projectContactUsers(ctx, userID, &list); err != nil {
return domain.Contact{}, err
}
if len(list.Contacts) == 0 {
return domain.Contact{}, nil
}
return list.Contacts[0], nil
}
func (s *Service) projectSearchResult(ctx context.Context, userID int64, res domain.UserSearchResult) (domain.UserSearchResult, error) {
if s == nil || s.projector == nil {
return res, nil
}
var err error
res.MyResults, err = s.projector.ForViewer(ctx, userID, res.MyResults)
if err != nil {
return domain.UserSearchResult{}, err
}
res.Results, err = s.projector.ForViewer(ctx, userID, res.Results)
if err != nil {
return domain.UserSearchResult{}, err
}
return res, nil
}
func normalizePhone(phone string) string {
if !utf8.ValidString(phone) {
return ""

View file

@ -21,7 +21,9 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
if err != nil {
t.Fatalf("create target: %v", err)
}
svc := NewService(contactsStore, users)
svc := NewService(contactsStore, users).Configure(WithPhotoProvider(contactProfilePhotos{
target.ID: {PhotoID: 9400, DCID: 2},
}))
res, err := svc.ImportContacts(ctx, owner.ID, []domain.ContactInput{
{ClientID: 11, Phone: "+1 (555) 123-4567", FirstName: "A"},
@ -42,6 +44,49 @@ func TestImportContactsBatchesPhonesAndDedupesUpserts(t *testing.T) {
if res.Contacts[0].FirstName != "Alice Final" {
t.Fatalf("contact first name = %q, want final input", res.Contacts[0].FirstName)
}
if res.Contacts[0].User.PhotoID != 9400 || res.Contacts[0].User.PhotoDCID != 2 {
t.Fatalf("imported contact photo = id %d dc %d, want 9400/2", res.Contacts[0].User.PhotoID, res.Contacts[0].User.PhotoDCID)
}
}
func TestGetContactsProjectsCurrentProfilePhoto(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: "Alice"})
if err != nil {
t.Fatalf("create target: %v", err)
}
if _, err := contactsStore.Upsert(ctx, owner.ID, domain.ContactInput{
ContactUserID: target.ID,
Phone: "1111",
FirstName: "Alice",
LastName: "Saved",
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
svc := NewService(contactsStore, users).Configure(WithPhotoProvider(contactProfilePhotos{
target.ID: {PhotoID: 9401, DCID: 2, Stripped: []byte{11, 12}},
}))
list, notModified, err := svc.GetContacts(ctx, owner.ID, 0)
if err != nil {
t.Fatalf("GetContacts: %v", err)
}
if notModified || len(list.Contacts) != 1 {
t.Fatalf("contacts notModified=%v len=%d, want one full contact", notModified, len(list.Contacts))
}
contact := list.Contacts[0]
if contact.User.PhotoID != 9401 || contact.User.PhotoDCID != 2 || string(contact.User.PhotoStripped) != string([]byte{11, 12}) {
t.Fatalf("contact user photo = id %d dc %d stripped %v, want 9401/2/[11 12]", contact.User.PhotoID, contact.User.PhotoDCID, contact.User.PhotoStripped)
}
if contact.User.FirstName != "Alice" || contact.User.LastName != "Saved" || contact.User.Phone != "1111" {
t.Fatalf("contact user projection = %+v, want contact name/phone", contact.User)
}
}
func TestAcceptContactSharesPhoneAndClearsShareContact(t *testing.T) {
@ -130,3 +175,15 @@ func TestAcceptContactRequiresExistingContactRequest(t *testing.T) {
t.Fatalf("AcceptContact without contact err = %v, want ErrContactReqMissing", err)
}
}
type contactProfilePhotos map[int64]domain.ProfilePhotoRef
func (p contactProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
for _, id := range ids {
if ref, ok := p[id]; ok {
out[id] = ref
}
}
return out, nil
}