business: add privacy-aware user projection
(cherry picked from commit a636192ef22ee69d792b0ca7db1c6be963be9cb2)
This commit is contained in:
parent
14d220c971
commit
8ff3343ae0
29 changed files with 2239 additions and 112 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,37 @@ import (
|
|||
"sort"
|
||||
"unicode/utf8"
|
||||
|
||||
"telesrv/internal/app/userprojection"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// Service 提供会话列表查询。
|
||||
type Service struct {
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
dialogs store.DialogStore
|
||||
channels store.ChannelStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
// Option adjusts optional dialogs service dependencies.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithContactStore enables viewer-specific user projection for dialog users.
|
||||
func WithContactStore(c store.ContactStore) Option {
|
||||
return func(s *Service) { s.contacts = c }
|
||||
}
|
||||
|
||||
// WithPhotoProvider enables current profile photo enrichment for dialog users.
|
||||
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
|
||||
return func(s *Service) { s.photos = p }
|
||||
}
|
||||
|
||||
// WithPrivacyEvaluator enables viewer-specific privacy projection for dialog users.
|
||||
func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
||||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
// NewService 创建 dialogs 服务。
|
||||
|
|
@ -24,9 +47,33 @@ func NewService(dialogs store.DialogStore, channels ...store.ChannelStore) *Serv
|
|||
if len(channels) > 0 {
|
||||
s.channels = channels[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),
|
||||
)
|
||||
}
|
||||
|
||||
// GetDialogs 返回当前登录账号的会话摘要。未登录或无持久化实现时按空账号处理。
|
||||
func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.DialogFilter) (domain.DialogList, error) {
|
||||
if s == nil || userID == 0 {
|
||||
|
|
@ -81,6 +128,9 @@ func (s *Service) GetDialogs(ctx context.Context, userID int64, filter domain.Di
|
|||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -124,6 +174,9 @@ func (s *Service) GetPeerDialogs(ctx context.Context, userID int64, peers []doma
|
|||
if err := s.attachDrafts(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
if err := s.projectDialogUsers(ctx, userID, &out); err != nil {
|
||||
return domain.DialogList{}, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
|
@ -439,6 +492,18 @@ func (s *Service) attachDrafts(ctx context.Context, userID int64, list *domain.D
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) projectDialogUsers(ctx context.Context, userID int64, list *domain.DialogList) error {
|
||||
if s == nil || s.projector == nil || list == nil || len(list.Users) == 0 {
|
||||
return nil
|
||||
}
|
||||
users, err := s.projector.ForViewer(ctx, userID, list.Users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
list.Users = users
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDraft(draft domain.DialogDraft) error {
|
||||
if err := validateDraftKey(draft.Peer, draft.TopMessageID); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -62,6 +62,64 @@ func TestGetDialogsIncludesChannelReadOutboxAfterOfflineRead(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGetDialogsProjectsUsersWithCurrentProfilePhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const ownerID int64 = 1001
|
||||
const peerID int64 = 1002
|
||||
dialogStore := memory.NewDialogStore()
|
||||
if err := dialogStore.SaveList(ctx, ownerID, domain.DialogList{
|
||||
Dialogs: []domain.Dialog{{
|
||||
Peer: domain.Peer{Type: domain.PeerTypeUser, ID: peerID},
|
||||
TopMessage: 1,
|
||||
TopMessageDate: 20,
|
||||
}},
|
||||
Users: []domain.User{{
|
||||
ID: peerID,
|
||||
AccessHash: 22,
|
||||
Phone: "15550000002",
|
||||
FirstName: "Alice A",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveList: %v", err)
|
||||
}
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, ownerID, domain.ContactInput{
|
||||
ContactUserID: peerID,
|
||||
Phone: "1111",
|
||||
FirstName: "Alice",
|
||||
LastName: "Saved",
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
dialogs := NewService(dialogStore).Configure(
|
||||
WithContactStore(contacts),
|
||||
WithPhotoProvider(dialogProfilePhotos{
|
||||
peerID: {PhotoID: 9201, DCID: 2, Stripped: []byte{7, 8}},
|
||||
}),
|
||||
)
|
||||
|
||||
list, err := dialogs.GetDialogs(ctx, ownerID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("GetDialogs: %v", err)
|
||||
}
|
||||
peer := findDialogUser(t, list.Users, peerID)
|
||||
if peer.PhotoID != 9201 || peer.PhotoDCID != 2 || string(peer.PhotoStripped) != string([]byte{7, 8}) {
|
||||
t.Fatalf("dialog user photo = id %d dc %d stripped %v, want 9201/2/[7 8]", peer.PhotoID, peer.PhotoDCID, peer.PhotoStripped)
|
||||
}
|
||||
if !peer.Contact || peer.FirstName != "Alice" || peer.LastName != "Saved" || peer.Phone != "1111" {
|
||||
t.Fatalf("dialog user projection = %+v, want contact view", peer)
|
||||
}
|
||||
|
||||
peerList, err := dialogs.GetPeerDialogs(ctx, ownerID, []domain.Peer{{Type: domain.PeerTypeUser, ID: peerID}})
|
||||
if err != nil {
|
||||
t.Fatalf("GetPeerDialogs: %v", err)
|
||||
}
|
||||
peer = findDialogUser(t, peerList.Users, peerID)
|
||||
if peer.PhotoID != 9201 || peer.PhotoDCID != 2 {
|
||||
t.Fatalf("peer dialog user photo = id %d dc %d, want 9201/2", peer.PhotoID, peer.PhotoDCID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelDialogSettingsPersistThroughUnifiedDialogService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
channelStore := memory.NewChannelStore()
|
||||
|
|
@ -280,3 +338,26 @@ func findChannelDialog(t *testing.T, list domain.DialogList, channelID int64) do
|
|||
t.Fatalf("channel dialog %d not found in %+v", channelID, list.Dialogs)
|
||||
return domain.Dialog{}
|
||||
}
|
||||
|
||||
func findDialogUser(t *testing.T, users []domain.User, userID int64) domain.User {
|
||||
t.Helper()
|
||||
for _, user := range users {
|
||||
if user.ID == userID {
|
||||
return user
|
||||
}
|
||||
}
|
||||
t.Fatalf("user %d not found in %+v", userID, users)
|
||||
return domain.User{}
|
||||
}
|
||||
|
||||
type dialogProfilePhotos map[int64]domain.ProfilePhotoRef
|
||||
|
||||
func (p dialogProfilePhotos) 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ import (
|
|||
|
||||
// UploadProfilePhoto 把已上传文件组装成头像 Photo,落 blob/photos/profile_photos,并设为当前头像。
|
||||
func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error) {
|
||||
return s.UploadProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, file, date)
|
||||
}
|
||||
|
||||
// UploadProfilePhotoKind stores a profile or fallback photo and makes it current for that kind.
|
||||
func (s *Service) UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error) {
|
||||
data, err := s.assembleUpload(ctx, file.OwnerUserID, file.FileID, file.Parts)
|
||||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
|
|
@ -33,7 +38,7 @@ func (s *Service) UploadProfilePhoto(ctx context.Context, ownerType domain.PeerT
|
|||
if err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photo.ID, date); err != nil {
|
||||
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photo.ID, date); err != nil {
|
||||
return domain.Photo{}, err
|
||||
}
|
||||
return photo, nil
|
||||
|
|
@ -133,6 +138,11 @@ func (s *Service) CreateDocumentFromUpload(ctx context.Context, file domain.Uplo
|
|||
|
||||
// SetCurrentProfilePhoto 把已存在的 photo 设为当前头像(updateProfilePhoto 选历史头像)。
|
||||
func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error) {
|
||||
return s.SetCurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date)
|
||||
}
|
||||
|
||||
// SetCurrentProfilePhotoKind sets an existing photo as current for profile or fallback history.
|
||||
func (s *Service) SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error) {
|
||||
photo, ok, err := s.media.GetPhoto(ctx, photoID)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
|
|
@ -140,7 +150,7 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P
|
|||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
if err := s.media.AddProfilePhoto(ctx, ownerType, ownerID, photoID, date); err != nil {
|
||||
if err := s.media.AddProfilePhotoKind(ctx, ownerType, ownerID, kind, photoID, date); err != nil {
|
||||
return domain.Photo{}, false, err
|
||||
}
|
||||
return photo, true, nil
|
||||
|
|
@ -148,7 +158,12 @@ func (s *Service) SetCurrentProfilePhoto(ctx context.Context, ownerType domain.P
|
|||
|
||||
// CurrentProfilePhoto 返回某 owner 的当前头像 Photo。
|
||||
func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error) {
|
||||
id, ok, err := s.media.CurrentProfilePhoto(ctx, ownerType, ownerID)
|
||||
return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
// CurrentProfilePhotoKind returns the current profile/fallback photo.
|
||||
func (s *Service) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) {
|
||||
id, ok, err := s.media.CurrentProfilePhotoKind(ctx, ownerType, ownerID, kind)
|
||||
if err != nil || !ok {
|
||||
return domain.Photo{}, ok, err
|
||||
}
|
||||
|
|
@ -157,7 +172,12 @@ func (s *Service) CurrentProfilePhoto(ctx context.Context, ownerType domain.Peer
|
|||
|
||||
// GetProfilePhotos 返回 owner 的头像历史(最新在前)。
|
||||
func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
ids, total, err := s.media.ListProfilePhotos(ctx, ownerType, ownerID, offset, limit, maxID)
|
||||
return s.GetProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID)
|
||||
}
|
||||
|
||||
// GetProfilePhotosKind returns profile/fallback photo history.
|
||||
func (s *Service) GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]domain.Photo, int, error) {
|
||||
ids, total, err := s.media.ListProfilePhotosKind(ctx, ownerType, ownerID, kind, offset, limit, maxID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
|
@ -174,7 +194,12 @@ func (s *Service) GetProfilePhotos(ctx context.Context, ownerType domain.PeerTyp
|
|||
|
||||
// DeleteProfilePhotos 停用指定头像,返回成功停用数量。
|
||||
func (s *Service) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error) {
|
||||
deleted, err := s.media.DeleteProfilePhotos(ctx, ownerType, ownerID, photoIDs)
|
||||
return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs)
|
||||
}
|
||||
|
||||
// DeleteProfilePhotosKind disables profile/fallback photos of the selected kind.
|
||||
func (s *Service) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error) {
|
||||
deleted, err := s.media.DeleteProfilePhotosKind(ctx, ownerType, ownerID, kind, photoIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,18 +159,33 @@ func (f *fakeMediaStore) CountAvailableReactions(_ context.Context) (int, error)
|
|||
func (f *fakeMediaStore) AddProfilePhoto(_ context.Context, _ domain.PeerType, _, _ int64, _ int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) AddProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ int64, _ int) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhoto(_ context.Context, _ domain.PeerType, _ int64) (int64, bool, error) {
|
||||
return 0, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotoKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind) (int64, bool, error) {
|
||||
return 0, false, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, _ []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
func (f *fakeMediaStore) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, _ []int64, _ domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _, _ int, _ int64) ([]int64, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeMediaStore) ListProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _, _ int, _ int64) ([]int64, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotos(_ context.Context, _ domain.PeerType, _ int64, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeMediaStore) DeleteProfilePhotosKind(_ context.Context, _ domain.PeerType, _ int64, _ domain.ProfilePhotoKind, _ []int64) ([]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestSeedMediaRepairsPartialReactionBlobs(t *testing.T) {
|
||||
seedDir := t.TempDir()
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import (
|
|||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
// Option adjusts optional message service dependencies.
|
||||
|
|
@ -23,12 +26,27 @@ func WithContactStore(c store.ContactStore) Option {
|
|||
return func(s *Service) { s.contacts = c }
|
||||
}
|
||||
|
||||
// WithPhotoProvider enables current profile photo enrichment for message users.
|
||||
func WithPhotoProvider(p userprojection.ProfilePhotoProvider) Option {
|
||||
return func(s *Service) { s.photos = p }
|
||||
}
|
||||
|
||||
// WithPrivacyEvaluator enables viewer-specific privacy projection for message users.
|
||||
func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
||||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
// NewService 创建 messages 服务。
|
||||
func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...Option) *Service {
|
||||
s := &Service{messages: messages, dialogs: dialogs}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
s.projector = userprojection.New(
|
||||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -202,7 +220,10 @@ func (s *Service) list(ctx context.Context, userID int64, filter domain.MessageF
|
|||
}
|
||||
|
||||
func (s *Service) projectMessageUsers(ctx context.Context, userID int64, list domain.MessageList) (domain.MessageList, error) {
|
||||
users, err := userprojection.ForViewer(ctx, s.contacts, userID, list.Users)
|
||||
if s == nil || s.projector == nil {
|
||||
return list, nil
|
||||
}
|
||||
users, err := s.projector.ForViewer(ctx, userID, list.Users)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
|||
{ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"},
|
||||
},
|
||||
}}
|
||||
svc := NewService(store, nil, WithContactStore(contacts))
|
||||
svc := NewService(store, nil, WithContactStore(contacts), WithPhotoProvider(messageProfilePhotos{
|
||||
friendID: {PhotoID: 9101, DCID: 2, Stripped: []byte{5, 6}},
|
||||
strangerID: {PhotoID: 9102, DCID: 4},
|
||||
}))
|
||||
|
||||
list, err := svc.GetHistory(ctx, ownerID, domain.MessageFilter{Limit: 10})
|
||||
if err != nil {
|
||||
|
|
@ -39,10 +42,16 @@ func TestServiceProjectsMessageUsersForViewerContacts(t *testing.T) {
|
|||
if !friend.Contact || friend.FirstName != "Remark" || friend.LastName != "Friend" || friend.Phone != "15550000002" {
|
||||
t.Fatalf("friend projection = %+v, want contact remark and phone", friend)
|
||||
}
|
||||
if friend.PhotoID != 9101 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{5, 6}) {
|
||||
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9101/2/[5 6]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
|
||||
}
|
||||
stranger := findUser(t, list.Users, strangerID)
|
||||
if stranger.Contact || stranger.Phone != "" || stranger.FirstName != "Stranger" {
|
||||
t.Fatalf("stranger projection = %+v, want non-contact with hidden phone", stranger)
|
||||
}
|
||||
if stranger.PhotoID != 9102 || stranger.PhotoDCID != 4 {
|
||||
t.Fatalf("stranger photo = id %d dc %d, want 9102/4", stranger.PhotoID, stranger.PhotoDCID)
|
||||
}
|
||||
self := findUser(t, list.Users, ownerID)
|
||||
if self.Phone != "15550000001" {
|
||||
t.Fatalf("self phone = %q, want preserved", self.Phone)
|
||||
|
|
@ -64,6 +73,18 @@ type projectionMessageStore struct {
|
|||
list domain.MessageList
|
||||
}
|
||||
|
||||
type messageProfilePhotos map[int64]domain.ProfilePhotoRef
|
||||
|
||||
func (p messageProfilePhotos) 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
|
||||
}
|
||||
|
||||
func (s projectionMessageStore) Create(context.Context, domain.Message) (domain.Message, error) {
|
||||
return domain.Message{}, nil
|
||||
}
|
||||
|
|
|
|||
268
internal/app/privacy/service.go
Normal file
268
internal/app/privacy/service.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
const maxPrivacyRules = 100
|
||||
|
||||
// Service owns account privacy rules and viewer-specific evaluation.
|
||||
type Service struct {
|
||||
rules store.PrivacyStore
|
||||
contacts store.ContactStore
|
||||
}
|
||||
|
||||
func NewService(rules store.PrivacyStore, contacts store.ContactStore) *Service {
|
||||
return &Service{rules: rules, contacts: contacts}
|
||||
}
|
||||
|
||||
func (s *Service) GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
if s == nil || s.rules == nil {
|
||||
return defaultRules(ownerUserID, key), nil
|
||||
}
|
||||
rules, ok, err := s.rules.GetPrivacyRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
if !ok {
|
||||
return defaultRules(ownerUserID, key), nil
|
||||
}
|
||||
rules.OwnerUserID = ownerUserID
|
||||
rules.Key = key
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(key)
|
||||
}
|
||||
return cloneRules(rules), nil
|
||||
}
|
||||
|
||||
func (s *Service) SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error) {
|
||||
if !ValidKey(key) {
|
||||
return domain.PrivacyRules{}, domain.ErrPrivacyKeyInvalid
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
rules = domain.DefaultPrivacyRules(key)
|
||||
}
|
||||
if err := validateRules(rules); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
out := domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: cloneRuleSlice(rules)}
|
||||
if s != nil && s.rules != nil {
|
||||
if err := s.rules.SetPrivacyRules(ctx, out); err != nil {
|
||||
return domain.PrivacyRules{}, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error) {
|
||||
if targetUserID == 0 {
|
||||
return domain.PrivacyRules{}, false, domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
rules, err := s.GetRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, false, err
|
||||
}
|
||||
for i := range rules.Rules {
|
||||
if rules.Rules[i].Kind != domain.PrivacyRuleAllowUsers {
|
||||
continue
|
||||
}
|
||||
if slices.Contains(rules.Rules[i].UserIDs, targetUserID) {
|
||||
return rules, false, nil
|
||||
}
|
||||
rules.Rules[i].UserIDs = append(rules.Rules[i].UserIDs, targetUserID)
|
||||
next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules)
|
||||
return next, true, err
|
||||
}
|
||||
rules.Rules = append([]domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowUsers, UserIDs: []int64{targetUserID}}}, rules.Rules...)
|
||||
next, err := s.SetRules(ctx, ownerUserID, key, rules.Rules)
|
||||
return next, true, err
|
||||
}
|
||||
|
||||
func (s *Service) CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error) {
|
||||
if ownerUserID == 0 || viewerUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if ownerUserID == viewerUserID {
|
||||
return true, nil
|
||||
}
|
||||
rules, err := s.GetRules(ctx, ownerUserID, key)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
evalCtx := domain.PrivacyContext{
|
||||
OwnerUserID: ownerUserID,
|
||||
ViewerUserID: viewerUserID,
|
||||
}
|
||||
if s != nil && s.contacts != nil {
|
||||
if _, found, err := s.contacts.Get(ctx, ownerUserID, viewerUserID); err != nil {
|
||||
return false, err
|
||||
} else if found {
|
||||
evalCtx.ViewerIsContact = true
|
||||
}
|
||||
}
|
||||
return Evaluate(rules, evalCtx), nil
|
||||
}
|
||||
|
||||
func Evaluate(rules domain.PrivacyRules, ctx domain.PrivacyContext) bool {
|
||||
if ctx.OwnerUserID != 0 && ctx.OwnerUserID == ctx.ViewerUserID {
|
||||
return true
|
||||
}
|
||||
if len(rules.Rules) == 0 {
|
||||
rules.Rules = domain.DefaultPrivacyRules(rules.Key)
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitDisallowMatches(rule, ctx) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
if explicitAllowMatches(rule, ctx) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return false
|
||||
}
|
||||
case domain.PrivacyRuleAllowContacts:
|
||||
if ctx.ViewerIsContact {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, rule := range rules.Rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowAll:
|
||||
return false
|
||||
case domain.PrivacyRuleAllowAll:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ValidKey(key domain.PrivacyKey) bool {
|
||||
switch key {
|
||||
case domain.PrivacyKeyStatusTimestamp,
|
||||
domain.PrivacyKeyChatInvite,
|
||||
domain.PrivacyKeyPhoneCall,
|
||||
domain.PrivacyKeyPhoneP2P,
|
||||
domain.PrivacyKeyForwards,
|
||||
domain.PrivacyKeyProfilePhoto,
|
||||
domain.PrivacyKeyPhoneNumber,
|
||||
domain.PrivacyKeyAddedByPhone,
|
||||
domain.PrivacyKeyVoiceMessages,
|
||||
domain.PrivacyKeyAbout,
|
||||
domain.PrivacyKeyBirthday,
|
||||
domain.PrivacyKeyStarGiftsAutoSave,
|
||||
domain.PrivacyKeyNoPaidMessages,
|
||||
domain.PrivacyKeySavedMusic:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateRules(rules []domain.PrivacyRule) error {
|
||||
if len(rules) > maxPrivacyRules {
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowContacts,
|
||||
domain.PrivacyRuleAllowAll,
|
||||
domain.PrivacyRuleAllowUsers,
|
||||
domain.PrivacyRuleDisallowContacts,
|
||||
domain.PrivacyRuleDisallowAll,
|
||||
domain.PrivacyRuleDisallowUsers,
|
||||
domain.PrivacyRuleAllowChatParticipants,
|
||||
domain.PrivacyRuleDisallowChatParticipants,
|
||||
domain.PrivacyRuleAllowCloseFriends,
|
||||
domain.PrivacyRuleAllowPremium,
|
||||
domain.PrivacyRuleAllowBots,
|
||||
domain.PrivacyRuleDisallowBots:
|
||||
default:
|
||||
return domain.ErrPrivacyRuleInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func explicitDisallowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleDisallowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleDisallowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleDisallowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func explicitAllowMatches(rule domain.PrivacyRule, ctx domain.PrivacyContext) bool {
|
||||
switch rule.Kind {
|
||||
case domain.PrivacyRuleAllowUsers:
|
||||
return slices.Contains(rule.UserIDs, ctx.ViewerUserID)
|
||||
case domain.PrivacyRuleAllowChatParticipants:
|
||||
return intersects(rule.ChatIDs, ctx.SharedChatIDs)
|
||||
case domain.PrivacyRuleAllowCloseFriends:
|
||||
return ctx.ViewerCloseFriend
|
||||
case domain.PrivacyRuleAllowPremium:
|
||||
return ctx.ViewerIsPremium
|
||||
case domain.PrivacyRuleAllowBots:
|
||||
return ctx.ViewerIsBot
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func intersects(a, b []int64) bool {
|
||||
if len(a) == 0 || len(b) == 0 {
|
||||
return false
|
||||
}
|
||||
set := make(map[int64]struct{}, len(a))
|
||||
for _, id := range a {
|
||||
set[id] = struct{}{}
|
||||
}
|
||||
for _, id := range b {
|
||||
if _, ok := set[id]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultRules(ownerUserID int64, key domain.PrivacyKey) domain.PrivacyRules {
|
||||
return domain.PrivacyRules{
|
||||
OwnerUserID: ownerUserID,
|
||||
Key: key,
|
||||
Rules: domain.DefaultPrivacyRules(key),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneRules(in domain.PrivacyRules) domain.PrivacyRules {
|
||||
out := in
|
||||
out.Rules = cloneRuleSlice(in.Rules)
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneRuleSlice(in []domain.PrivacyRule) []domain.PrivacyRule {
|
||||
out := make([]domain.PrivacyRule, len(in))
|
||||
for i, rule := range in {
|
||||
out[i] = rule
|
||||
out[i].UserIDs = append([]int64(nil), rule.UserIDs...)
|
||||
out[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
75
internal/app/privacy/service_test.go
Normal file
75
internal/app/privacy/service_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package privacy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestDefaultPrivacyRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
phone, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("phone rules: %v", err)
|
||||
}
|
||||
if len(phone.Rules) != 1 || phone.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
|
||||
t.Fatalf("phone default = %+v, want disallow all", phone.Rules)
|
||||
}
|
||||
birthday, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyBirthday)
|
||||
if err != nil {
|
||||
t.Fatalf("birthday rules: %v", err)
|
||||
}
|
||||
if len(birthday.Rules) != 1 || birthday.Rules[0].Kind != domain.PrivacyRuleAllowContacts {
|
||||
t.Fatalf("birthday default = %+v, want allow contacts", birthday.Rules)
|
||||
}
|
||||
profile, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil {
|
||||
t.Fatalf("profile rules: %v", err)
|
||||
}
|
||||
if len(profile.Rules) != 1 || profile.Rules[0].Kind != domain.PrivacyRuleAllowAll {
|
||||
t.Fatalf("profile default = %+v, want allow all", profile.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAllowUserOverridesDisallowAll(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
|
||||
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set rules: %v", err)
|
||||
}
|
||||
allowed, err := svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("can see before: %v", err)
|
||||
}
|
||||
if allowed {
|
||||
t.Fatal("viewer should not see phone before exception")
|
||||
}
|
||||
if _, changed, err := svc.AddAllowUser(ctx, 1001, domain.PrivacyKeyPhoneNumber, 1002); err != nil {
|
||||
t.Fatalf("add allow: %v", err)
|
||||
} else if !changed {
|
||||
t.Fatal("first add allow should report changed")
|
||||
}
|
||||
allowed, err = svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
t.Fatalf("can see after: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
t.Fatal("viewer should see phone after allow-user exception")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitDisallowUserWins(t *testing.T) {
|
||||
rules := domain.PrivacyRules{
|
||||
Key: domain.PrivacyKeyProfilePhoto,
|
||||
Rules: []domain.PrivacyRule{
|
||||
{Kind: domain.PrivacyRuleAllowAll},
|
||||
{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{1002}},
|
||||
},
|
||||
}
|
||||
if Evaluate(rules, domain.PrivacyContext{OwnerUserID: 1001, ViewerUserID: 1002}) {
|
||||
t.Fatal("explicit disallow user should win over allow all")
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,111 @@ import (
|
|||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
// ProfilePhotoProvider returns current profile photos for a batch of owners.
|
||||
type ProfilePhotoProvider interface {
|
||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
}
|
||||
|
||||
// ProfilePhotoKindProvider returns current profile/fallback photos for a batch of owners.
|
||||
type ProfilePhotoKindProvider interface {
|
||||
CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error)
|
||||
}
|
||||
|
||||
// PrivacyEvaluator answers viewer-specific visibility for one user privacy key.
|
||||
type PrivacyEvaluator interface {
|
||||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// Projector builds the current viewer's user view for RPC response payloads.
|
||||
// It intentionally stays in app/domain types; tg.* conversion remains in rpc.
|
||||
type Projector struct {
|
||||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy PrivacyEvaluator
|
||||
}
|
||||
|
||||
// Option configures a Projector.
|
||||
type Option func(*Projector)
|
||||
|
||||
// WithContactStore enables viewer-specific contact name/phone projection.
|
||||
func WithContactStore(c store.ContactStore) Option {
|
||||
return func(p *Projector) { p.contacts = c }
|
||||
}
|
||||
|
||||
// WithPhotoProvider enables current profile photo enrichment.
|
||||
func WithPhotoProvider(photos ProfilePhotoProvider) Option {
|
||||
return func(p *Projector) { p.photos = photos }
|
||||
}
|
||||
|
||||
// WithPrivacyEvaluator enables profile/photo/status privacy projection.
|
||||
func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
|
||||
return func(p *Projector) { p.privacy = privacy }
|
||||
}
|
||||
|
||||
// New creates a user projector.
|
||||
func New(opts ...Option) *Projector {
|
||||
p := &Projector{}
|
||||
for _, opt := range opts {
|
||||
opt(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ForViewer applies both current profile photos and owner-specific contact view.
|
||||
func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
|
||||
}
|
||||
|
||||
// One applies ForViewer to a single user.
|
||||
func (p *Projector) One(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
if p == nil {
|
||||
return user, nil
|
||||
}
|
||||
projected, err := p.ForViewer(ctx, viewerUserID, []domain.User{user})
|
||||
if err != nil || len(projected) == 0 {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return projected[0], nil
|
||||
}
|
||||
|
||||
// WithProfilePhotos enriches users with their current avatar from profile photo storage.
|
||||
// The lookup is best-effort: a storage error keeps the original user list.
|
||||
func WithProfilePhotos(ctx context.Context, photos ProfilePhotoProvider, users []domain.User) []domain.User {
|
||||
if photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[u.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[u.ID] = struct{}{}
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return users
|
||||
}
|
||||
refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil || len(refs) == 0 {
|
||||
return users
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
for i := range out {
|
||||
if ref, ok := refs[out[i].ID]; ok {
|
||||
applyPhotoRef(&out[i], ref)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
|
@ -45,6 +150,74 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u
|
|||
return projected[0], nil
|
||||
}
|
||||
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
out := make([]domain.User, len(users))
|
||||
copy(out, users)
|
||||
ids := uniqueUserIDs(out)
|
||||
profileRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
fallbackRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
personalRefs := map[int64]domain.ProfilePhotoRef{}
|
||||
if photos != nil && len(ids) > 0 {
|
||||
if kindPhotos, ok := photos.(ProfilePhotoKindProvider); ok {
|
||||
refs, err := kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRefs = refs
|
||||
refs, err = kindPhotos.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, ids, domain.ProfilePhotoKindFallback)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fallbackRefs = refs
|
||||
} else {
|
||||
refs, err := photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profileRefs = refs
|
||||
}
|
||||
}
|
||||
var contactsByID map[int64]domain.Contact
|
||||
if contacts != nil && viewerUserID != 0 && len(ids) > 0 {
|
||||
var err error
|
||||
contactsByID, err = contacts.GetMany(ctx, viewerUserID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
personalRefs, err = contacts.PersonalPhotos(ctx, viewerUserID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cache := make(map[int64]domain.User, len(out))
|
||||
for i := range out {
|
||||
u := out[i]
|
||||
if u.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if projected, ok := cache[u.ID]; ok {
|
||||
out[i] = projected
|
||||
continue
|
||||
}
|
||||
projected := applyBasePhotos(u, profileRefs, fallbackRefs, personalRefs, viewerUserID)
|
||||
if viewerUserID != 0 && u.ID != viewerUserID && u.ID != domain.OfficialSystemUserID {
|
||||
contact, found := contactsByID[u.ID]
|
||||
projected = applyContactProjection(projected, contact, found)
|
||||
var err error
|
||||
projected, err = applyPrivacy(ctx, privacy, viewerUserID, projected, found, profileRefs, fallbackRefs, personalRefs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
cache[u.ID] = projected
|
||||
out[i] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
contact, found, err := contacts.Get(ctx, viewerUserID, user.ID)
|
||||
if err != nil {
|
||||
|
|
@ -73,3 +246,130 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
|
|||
}
|
||||
return projected, nil
|
||||
}
|
||||
|
||||
func uniqueUserIDs(users []domain.User) []int64 {
|
||||
seen := make(map[int64]struct{}, len(users))
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.ID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[user.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[user.ID] = struct{}{}
|
||||
ids = append(ids, user.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func applyBasePhotos(user domain.User, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef, viewerUserID int64) domain.User {
|
||||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) {
|
||||
return user
|
||||
}
|
||||
clearPhoto(&user)
|
||||
if viewerUserID != 0 && user.ID != viewerUserID {
|
||||
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
ref.Personal = true
|
||||
applyPhotoRef(&user, ref)
|
||||
return user
|
||||
}
|
||||
}
|
||||
if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
applyPhotoRef(&user, ref)
|
||||
return user
|
||||
}
|
||||
if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
applyPhotoRef(&user, ref)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func applyContactProjection(user domain.User, contact domain.Contact, found bool) domain.User {
|
||||
if !found {
|
||||
user.Phone = ""
|
||||
user.Contact = false
|
||||
user.Mutual = false
|
||||
return user
|
||||
}
|
||||
user.Contact = true
|
||||
user.Mutual = contact.Mutual || contact.User.Mutual
|
||||
if contact.User.Phone != "" {
|
||||
user.Phone = contact.User.Phone
|
||||
} else {
|
||||
user.Phone = contact.Phone
|
||||
}
|
||||
if contact.User.FirstName != "" || contact.User.LastName != "" {
|
||||
user.FirstName = contact.User.FirstName
|
||||
user.LastName = contact.User.LastName
|
||||
} else if contact.FirstName != "" || contact.LastName != "" {
|
||||
user.FirstName = contact.FirstName
|
||||
user.LastName = contact.LastName
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func applyPrivacy(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, user domain.User, isContact bool, profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) (domain.User, error) {
|
||||
if privacy == nil {
|
||||
return user, nil
|
||||
}
|
||||
phoneAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyPhoneNumber)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !phoneAllowed && !isContact {
|
||||
user.Phone = ""
|
||||
}
|
||||
statusAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyStatusTimestamp)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !statusAllowed {
|
||||
user.LastSeenAt = 0
|
||||
if user.Status.Kind == domain.UserStatusOnline || user.Status.Kind == domain.UserStatusOffline {
|
||||
user.Status = domain.UserStatus{Kind: domain.UserStatusRecently}
|
||||
}
|
||||
}
|
||||
if ref, ok := personalRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
ref.Personal = true
|
||||
applyPhotoRef(&user, ref)
|
||||
return user, nil
|
||||
}
|
||||
if !hasPhotoLookups(profileRefs, fallbackRefs, personalRefs) && user.PhotoID == 0 {
|
||||
return user, nil
|
||||
}
|
||||
profileAllowed, err := privacy.CanSee(ctx, user.ID, viewerUserID, domain.PrivacyKeyProfilePhoto)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if profileAllowed {
|
||||
if ref, ok := profileRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
applyPhotoRef(&user, ref)
|
||||
return user, nil
|
||||
}
|
||||
}
|
||||
if ref, ok := fallbackRefs[user.ID]; ok && ref.PhotoID != 0 {
|
||||
applyPhotoRef(&user, ref)
|
||||
return user, nil
|
||||
}
|
||||
clearPhoto(&user)
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func hasPhotoLookups(profileRefs, fallbackRefs, personalRefs map[int64]domain.ProfilePhotoRef) bool {
|
||||
return len(profileRefs) != 0 || len(fallbackRefs) != 0 || len(personalRefs) != 0
|
||||
}
|
||||
|
||||
func applyPhotoRef(user *domain.User, ref domain.ProfilePhotoRef) {
|
||||
user.PhotoID = ref.PhotoID
|
||||
user.PhotoDCID = ref.DCID
|
||||
user.PhotoStripped = append([]byte(nil), ref.Stripped...)
|
||||
user.PhotoPersonal = ref.Personal
|
||||
}
|
||||
|
||||
func clearPhoto(user *domain.User) {
|
||||
user.PhotoID = 0
|
||||
user.PhotoDCID = 0
|
||||
user.PhotoStripped = nil
|
||||
user.PhotoPersonal = false
|
||||
}
|
||||
|
|
|
|||
148
internal/app/userprojection/projection_test.go
Normal file
148
internal/app/userprojection/projection_test.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package userprojection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const viewerID int64 = 1001
|
||||
const friendID int64 = 1002
|
||||
const strangerID int64 = 1003
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{
|
||||
ContactUserID: friendID,
|
||||
Phone: "1111",
|
||||
FirstName: "Alice",
|
||||
LastName: "Contact",
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPhotoProvider(fakeProfilePhotos{
|
||||
profile: map[int64]domain.ProfilePhotoRef{
|
||||
friendID: {PhotoID: 9001, DCID: 2, Stripped: []byte{1, 2}},
|
||||
strangerID: {PhotoID: 9002, DCID: 3, Stripped: []byte{3, 4}},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
users, err := projector.ForViewer(ctx, viewerID, []domain.User{
|
||||
{ID: viewerID, Phone: "15550000001", FirstName: "Owner"},
|
||||
{ID: friendID, AccessHash: 22, Phone: "15550000002", FirstName: "Public", LastName: "Name"},
|
||||
{ID: strangerID, AccessHash: 33, Phone: "15550000003", FirstName: "Stranger"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer: %v", err)
|
||||
}
|
||||
|
||||
friend := projectionUser(t, users, friendID)
|
||||
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
|
||||
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
|
||||
}
|
||||
if friend.PhotoID != 9001 || friend.PhotoDCID != 2 || string(friend.PhotoStripped) != string([]byte{1, 2}) {
|
||||
t.Fatalf("friend photo = id %d dc %d stripped %v, want 9001/2/[1 2]", friend.PhotoID, friend.PhotoDCID, friend.PhotoStripped)
|
||||
}
|
||||
stranger := projectionUser(t, users, strangerID)
|
||||
if stranger.Phone != "" || stranger.Contact {
|
||||
t.Fatalf("stranger projection = %+v, want hidden phone and non-contact", stranger)
|
||||
}
|
||||
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
|
||||
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorPersonalPhotoWinsOverProfile(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const viewerID int64 = 2001
|
||||
const friendID int64 = 2002
|
||||
contacts := memory.NewContactStore()
|
||||
if _, err := contacts.Upsert(ctx, viewerID, domain.ContactInput{ContactUserID: friendID, FirstName: "Friend"}); err != nil {
|
||||
t.Fatalf("upsert contact: %v", err)
|
||||
}
|
||||
if _, _, err := contacts.SetPersonalPhoto(ctx, viewerID, friendID, 9100, 100); err != nil {
|
||||
t.Fatalf("set personal photo: %v", err)
|
||||
}
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPhotoProvider(fakeProfilePhotos{
|
||||
profile: map[int64]domain.ProfilePhotoRef{friendID: {PhotoID: 9001, DCID: 2}},
|
||||
}),
|
||||
)
|
||||
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: friendID, FirstName: "Public"}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer: %v", err)
|
||||
}
|
||||
friend := projectionUser(t, users, friendID)
|
||||
if friend.PhotoID != 9100 || !friend.PhotoPersonal {
|
||||
t.Fatalf("friend photo = id %d personal %v, want personal 9100", friend.PhotoID, friend.PhotoPersonal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const viewerID int64 = 3001
|
||||
const ownerID int64 = 3002
|
||||
contacts := memory.NewContactStore()
|
||||
rules := memory.NewPrivacyStore()
|
||||
privacy := privacyapp.NewService(rules, contacts)
|
||||
if _, err := privacy.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
|
||||
t.Fatalf("set privacy: %v", err)
|
||||
}
|
||||
projector := New(
|
||||
WithContactStore(contacts),
|
||||
WithPrivacyEvaluator(privacy),
|
||||
WithPhotoProvider(fakeProfilePhotos{
|
||||
profile: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9001, DCID: 2}},
|
||||
fallback: map[int64]domain.ProfilePhotoRef{ownerID: {PhotoID: 9002, DCID: 3}},
|
||||
}),
|
||||
)
|
||||
users, err := projector.ForViewer(ctx, viewerID, []domain.User{{ID: ownerID, Phone: "15550003002", FirstName: "Owner"}})
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer: %v", err)
|
||||
}
|
||||
owner := projectionUser(t, users, ownerID)
|
||||
if owner.PhotoID != 9002 || owner.PhotoDCID != 3 || owner.Phone != "" {
|
||||
t.Fatalf("owner projection = %+v, want fallback photo and hidden phone", owner)
|
||||
}
|
||||
}
|
||||
|
||||
func projectionUser(t *testing.T, users []domain.User, id int64) domain.User {
|
||||
t.Helper()
|
||||
for _, user := range users {
|
||||
if user.ID == id {
|
||||
return user
|
||||
}
|
||||
}
|
||||
t.Fatalf("user %d not found in %+v", id, users)
|
||||
return domain.User{}
|
||||
}
|
||||
|
||||
type fakeProfilePhotos struct {
|
||||
profile map[int64]domain.ProfilePhotoRef
|
||||
fallback map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (p fakeProfilePhotos) CurrentProfilePhotosKind(_ context.Context, _ domain.PeerType, ids []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
source := p.profile
|
||||
if kind == domain.ProfilePhotoKindFallback {
|
||||
source = p.fallback
|
||||
}
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ids))
|
||||
for _, id := range ids {
|
||||
if ref, ok := source[id]; ok {
|
||||
out[id] = ref
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
|
@ -15,15 +15,15 @@ import (
|
|||
var ErrNotAuthorized = errors.New("not authorized")
|
||||
|
||||
// ProfilePhotoProvider 批量返回用户当前头像(用于把 PhotoID/DCID/Stripped 富化到 domain.User)。
|
||||
type ProfilePhotoProvider interface {
|
||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
}
|
||||
type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
||||
|
||||
// Service 提供用户查询。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
users store.UserStore
|
||||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
// Option 调整用户服务可选依赖。
|
||||
|
|
@ -39,6 +39,11 @@ func WithContactStore(c store.ContactStore) Option {
|
|||
return func(s *Service) { s.contacts = c }
|
||||
}
|
||||
|
||||
// WithPrivacyEvaluator enables viewer-specific privacy projection.
|
||||
func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
||||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
|
|
@ -53,6 +58,11 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
s.projector = userprojection.New(
|
||||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +87,7 @@ func (s *Service) Self(ctx context.Context, userID int64) (domain.User, error) {
|
|||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
return s.enrichOne(ctx, u), nil
|
||||
return s.projectOne(ctx, userID, u)
|
||||
}
|
||||
|
||||
// ByID 返回指定用户。调用方必须已登录;access_hash 校验在 RPC 边界完成。
|
||||
|
|
@ -92,8 +102,7 @@ func (s *Service) ByID(ctx context.Context, currentUserID, userID int64) (domain
|
|||
if !found {
|
||||
return u, false, nil
|
||||
}
|
||||
u = s.enrichOne(ctx, u)
|
||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
|
|
@ -127,38 +136,7 @@ func (s *Service) ByIDs(ctx context.Context, currentUserID int64, userIDs []int6
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = s.enrich(ctx, users)
|
||||
return userprojection.ForViewer(ctx, s.contacts, currentUserID, users)
|
||||
}
|
||||
|
||||
// enrich 批量把当前头像富化到用户列表(best-effort:失败不影响用户查询)。
|
||||
func (s *Service) enrich(ctx context.Context, users []domain.User) []domain.User {
|
||||
if s.photos == nil || len(users) == 0 {
|
||||
return users
|
||||
}
|
||||
ids := make([]int64, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.ID != 0 {
|
||||
ids = append(ids, u.ID)
|
||||
}
|
||||
}
|
||||
refs, err := s.photos.CurrentProfilePhotos(ctx, domain.PeerTypeUser, ids)
|
||||
if err != nil {
|
||||
return users
|
||||
}
|
||||
for i := range users {
|
||||
if ref, ok := refs[users[i].ID]; ok {
|
||||
users[i].PhotoID = ref.PhotoID
|
||||
users[i].PhotoDCID = ref.DCID
|
||||
users[i].PhotoStripped = ref.Stripped
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func (s *Service) enrichOne(ctx context.Context, u domain.User) domain.User {
|
||||
enriched := s.enrich(ctx, []domain.User{u})
|
||||
return enriched[0]
|
||||
return s.projectUsers(ctx, currentUserID, users)
|
||||
}
|
||||
|
||||
// CheckUsername 校验当前用户是否可以占用 username。
|
||||
|
|
@ -261,8 +239,7 @@ func (s *Service) ResolveUsername(ctx context.Context, currentUserID int64, user
|
|||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
u = s.enrichOne(ctx, u)
|
||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
|
|
@ -282,14 +259,27 @@ func (s *Service) ResolvePhone(ctx context.Context, currentUserID int64, phone s
|
|||
if err != nil || !found {
|
||||
return u, found, err
|
||||
}
|
||||
u = s.enrichOne(ctx, u)
|
||||
u, err = userprojection.One(ctx, s.contacts, currentUserID, u)
|
||||
u, err = s.projectOne(ctx, currentUserID, u)
|
||||
if err != nil {
|
||||
return domain.User{}, false, err
|
||||
}
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) projectUsers(ctx context.Context, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if s == nil || s.projector == nil {
|
||||
return users, nil
|
||||
}
|
||||
return s.projector.ForViewer(ctx, viewerUserID, users)
|
||||
}
|
||||
|
||||
func (s *Service) projectOne(ctx context.Context, viewerUserID int64, user domain.User) (domain.User, error) {
|
||||
if s == nil || s.projector == nil {
|
||||
return user, nil
|
||||
}
|
||||
return s.projector.One(ctx, viewerUserID, user)
|
||||
}
|
||||
|
||||
func normalizeUsername(username string) string {
|
||||
username = strings.TrimSpace(username)
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue