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
|
|
@ -26,6 +26,7 @@ import (
|
|||
"telesrv/internal/app/langpack"
|
||||
"telesrv/internal/app/maintenance"
|
||||
messageapp "telesrv/internal/app/messages"
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
"telesrv/internal/app/updates"
|
||||
"telesrv/internal/app/users"
|
||||
"telesrv/internal/config"
|
||||
|
|
@ -125,6 +126,7 @@ func run(logger *zap.Logger) error {
|
|||
messageStore := postgres.NewMessageStore(pool, postgres.WithMessageAllocators(boxIDAllocator, ptsAllocator))
|
||||
channelStore := postgres.NewChannelStore(pool, postgres.WithChannelAllocators(channelIDAllocator, channelPtsAllocator, channelMessageIDAllocator))
|
||||
mediaStore := postgres.NewMediaStore(pool)
|
||||
privacyStore := postgres.NewPrivacyStore(pool)
|
||||
blobBackend, err := filesapp.NewLocalFS(cfg.BlobDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init blob backend: %w", err)
|
||||
|
|
@ -170,6 +172,7 @@ func run(logger *zap.Logger) error {
|
|||
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
|
||||
).Run(ctx)
|
||||
langPackService := langpack.NewService(langPackStore)
|
||||
privacyService := privacyapp.NewService(privacyStore, contactStore)
|
||||
if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil {
|
||||
return fmt.Errorf("seed langpack: %w", err)
|
||||
} else if seeded > 0 {
|
||||
|
|
@ -184,12 +187,13 @@ func run(logger *zap.Logger) error {
|
|||
}, rpc.Deps{
|
||||
Auth: auth.NewService(userStore, authzStore, codeStore, authKeyStore, tempAuthKeyStore, cfg.DevAuthCode, auth.WithLoginMessages(messageStore, dialogStore)),
|
||||
Account: account.NewService(passwordStore, account.WithReactionSettings(passwordStore)),
|
||||
Privacy: privacyService,
|
||||
Help: help.NewService(helpStore, helpStore),
|
||||
Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore)),
|
||||
Users: users.NewService(userStore, users.WithContactStore(contactStore), users.WithPhotoProvider(mediaStore), users.WithPrivacyEvaluator(privacyService)),
|
||||
Updates: updates.NewService(updateStateStore, updateEventStore, updates.WithPtsAllocator(ptsAllocator)),
|
||||
Contacts: contacts.NewService(contactStore, userStore),
|
||||
Dialogs: dialogs.NewService(dialogStore, channelStore),
|
||||
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore)),
|
||||
Contacts: contacts.NewService(contactStore, userStore).Configure(contacts.WithPhotoProvider(mediaStore), contacts.WithPrivacyEvaluator(privacyService)),
|
||||
Dialogs: dialogs.NewService(dialogStore, channelStore).Configure(dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(mediaStore), dialogs.WithPrivacyEvaluator(privacyService)),
|
||||
Messages: messageapp.NewService(messageStore, dialogStore, messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(mediaStore), messageapp.WithPrivacyEvaluator(privacyService)),
|
||||
Channels: channelapp.NewService(channelStore),
|
||||
Files: filesService,
|
||||
LangPack: langPackService,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
DROP INDEX IF EXISTS contacts_personal_photo_idx;
|
||||
|
||||
ALTER TABLE contacts
|
||||
DROP COLUMN IF EXISTS personal_photo_date,
|
||||
DROP COLUMN IF EXISTS personal_photo_id;
|
||||
|
||||
DROP INDEX IF EXISTS profile_photos_current_idx;
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
DROP CONSTRAINT IF EXISTS profile_photos_kind_check;
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
DROP CONSTRAINT IF EXISTS profile_photos_pkey;
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, photo_id);
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
DROP COLUMN IF EXISTS kind;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS profile_photos_current_idx
|
||||
ON profile_photos (owner_peer_type, owner_peer_id, sort_order DESC)
|
||||
WHERE active;
|
||||
|
||||
DROP TABLE IF EXISTS account_privacy_rules;
|
||||
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
-- 0069_privacy_and_profile_photo_kinds: account privacy rules, fallback photos,
|
||||
-- and viewer-private contact profile photos.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_privacy_rules (
|
||||
owner_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
privacy_key TEXT NOT NULL,
|
||||
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (owner_user_id, privacy_key)
|
||||
);
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'profile';
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
DROP CONSTRAINT IF EXISTS profile_photos_pkey;
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
ADD CONSTRAINT profile_photos_pkey PRIMARY KEY (owner_peer_type, owner_peer_id, kind, photo_id);
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
DROP CONSTRAINT IF EXISTS profile_photos_kind_check;
|
||||
|
||||
ALTER TABLE profile_photos
|
||||
ADD CONSTRAINT profile_photos_kind_check CHECK (kind IN ('profile', 'fallback'));
|
||||
|
||||
DROP INDEX IF EXISTS profile_photos_current_idx;
|
||||
CREATE INDEX IF NOT EXISTS profile_photos_current_idx
|
||||
ON profile_photos (owner_peer_type, owner_peer_id, kind, sort_order DESC)
|
||||
WHERE active;
|
||||
|
||||
ALTER TABLE contacts
|
||||
ADD COLUMN IF NOT EXISTS personal_photo_id BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS personal_photo_date INT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS contacts_personal_photo_idx
|
||||
ON contacts (user_id, contact_user_id)
|
||||
WHERE personal_photo_id <> 0;
|
||||
|
||||
|
|
@ -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, "@")
|
||||
|
|
|
|||
|
|
@ -258,11 +258,21 @@ type StickerSet struct {
|
|||
SystemKey string `json:"system_key,omitempty"`
|
||||
}
|
||||
|
||||
// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile photo)。
|
||||
// ProfilePhotoKind distinguishes a user's real profile photo from the fallback
|
||||
// public photo shown when privacy hides the real one.
|
||||
type ProfilePhotoKind string
|
||||
|
||||
const (
|
||||
ProfilePhotoKindProfile ProfilePhotoKind = "profile"
|
||||
ProfilePhotoKindFallback ProfilePhotoKind = "fallback"
|
||||
)
|
||||
|
||||
// ProfilePhotoRef 是渲染头像所需的最小信息(当前 profile/fallback/personal photo)。
|
||||
type ProfilePhotoRef struct {
|
||||
PhotoID int64
|
||||
DCID int
|
||||
Stripped []byte // photoStrippedSize 内联缩略图,可空
|
||||
Personal bool // true 表示 viewer 私有联系人头像
|
||||
}
|
||||
|
||||
// StrippedFromSizes 从照片尺寸列表里取出 stripped 缩略图字节(用于 UserProfilePhoto/ChatPhoto 占位)。
|
||||
|
|
|
|||
83
internal/domain/privacy.go
Normal file
83
internal/domain/privacy.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
// PrivacyKey identifies a Telegram account privacy setting without exposing tg.*.
|
||||
type PrivacyKey string
|
||||
|
||||
const (
|
||||
PrivacyKeyStatusTimestamp PrivacyKey = "status_timestamp"
|
||||
PrivacyKeyChatInvite PrivacyKey = "chat_invite"
|
||||
PrivacyKeyPhoneCall PrivacyKey = "phone_call"
|
||||
PrivacyKeyPhoneP2P PrivacyKey = "phone_p2p"
|
||||
PrivacyKeyForwards PrivacyKey = "forwards"
|
||||
PrivacyKeyProfilePhoto PrivacyKey = "profile_photo"
|
||||
PrivacyKeyPhoneNumber PrivacyKey = "phone_number"
|
||||
PrivacyKeyAddedByPhone PrivacyKey = "added_by_phone"
|
||||
PrivacyKeyVoiceMessages PrivacyKey = "voice_messages"
|
||||
PrivacyKeyAbout PrivacyKey = "about"
|
||||
PrivacyKeyBirthday PrivacyKey = "birthday"
|
||||
PrivacyKeyStarGiftsAutoSave PrivacyKey = "star_gifts_auto_save"
|
||||
PrivacyKeyNoPaidMessages PrivacyKey = "no_paid_messages"
|
||||
PrivacyKeySavedMusic PrivacyKey = "saved_music"
|
||||
)
|
||||
|
||||
// PrivacyRuleKind mirrors Layer 225 privacy rule constructors.
|
||||
type PrivacyRuleKind string
|
||||
|
||||
const (
|
||||
PrivacyRuleAllowContacts PrivacyRuleKind = "allow_contacts"
|
||||
PrivacyRuleAllowAll PrivacyRuleKind = "allow_all"
|
||||
PrivacyRuleAllowUsers PrivacyRuleKind = "allow_users"
|
||||
PrivacyRuleDisallowContacts PrivacyRuleKind = "disallow_contacts"
|
||||
PrivacyRuleDisallowAll PrivacyRuleKind = "disallow_all"
|
||||
PrivacyRuleDisallowUsers PrivacyRuleKind = "disallow_users"
|
||||
PrivacyRuleAllowChatParticipants PrivacyRuleKind = "allow_chat_participants"
|
||||
PrivacyRuleDisallowChatParticipants PrivacyRuleKind = "disallow_chat_participants"
|
||||
PrivacyRuleAllowCloseFriends PrivacyRuleKind = "allow_close_friends"
|
||||
PrivacyRuleAllowPremium PrivacyRuleKind = "allow_premium"
|
||||
PrivacyRuleAllowBots PrivacyRuleKind = "allow_bots"
|
||||
PrivacyRuleDisallowBots PrivacyRuleKind = "disallow_bots"
|
||||
)
|
||||
|
||||
// PrivacyRule is a protocol-neutral privacy rule.
|
||||
type PrivacyRule struct {
|
||||
Kind PrivacyRuleKind `json:"kind"`
|
||||
UserIDs []int64 `json:"user_ids,omitempty"`
|
||||
ChatIDs []int64 `json:"chat_ids,omitempty"`
|
||||
}
|
||||
|
||||
// PrivacyRules is one owner/key rule set.
|
||||
type PrivacyRules struct {
|
||||
OwnerUserID int64 `json:"owner_user_id,omitempty"`
|
||||
Key PrivacyKey `json:"key"`
|
||||
Rules []PrivacyRule `json:"rules"`
|
||||
}
|
||||
|
||||
// PrivacyContext describes viewer facts needed for privacy evaluation.
|
||||
type PrivacyContext struct {
|
||||
OwnerUserID int64
|
||||
ViewerUserID int64
|
||||
ViewerIsContact bool
|
||||
ViewerIsBot bool
|
||||
ViewerIsPremium bool
|
||||
ViewerCloseFriend bool
|
||||
SharedChatIDs []int64
|
||||
}
|
||||
|
||||
var (
|
||||
ErrPrivacyKeyInvalid = errors.New("privacy key invalid")
|
||||
ErrPrivacyRuleInvalid = errors.New("privacy rule invalid")
|
||||
)
|
||||
|
||||
// DefaultPrivacyRules returns Telegram-like defaults used when no user setting exists.
|
||||
func DefaultPrivacyRules(key PrivacyKey) []PrivacyRule {
|
||||
switch key {
|
||||
case PrivacyKeyPhoneNumber:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleDisallowAll}}
|
||||
case PrivacyKeyBirthday:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleAllowContacts}}
|
||||
default:
|
||||
return []PrivacyRule{{Kind: PrivacyRuleAllowAll}}
|
||||
}
|
||||
}
|
||||
|
|
@ -21,10 +21,11 @@ type User struct {
|
|||
Support bool
|
||||
Contact bool
|
||||
Mutual bool
|
||||
// Profile photo:反范式存于 users 表,便于无 join 渲染头像。PhotoID==0 表示无头像。
|
||||
// Profile photo fields are filled by app-layer user projection. PhotoID==0 表示无头像。
|
||||
PhotoID int64
|
||||
PhotoDCID int
|
||||
PhotoStripped []byte
|
||||
PhotoPersonal bool
|
||||
LastSeenAt int
|
||||
Status UserStatus
|
||||
}
|
||||
|
|
@ -61,3 +62,17 @@ type UserProfileUpdate struct {
|
|||
About string
|
||||
HasAbout bool
|
||||
}
|
||||
|
||||
// UserFullView is the app-layer personalized full user view consumed by RPC.
|
||||
type UserFullView struct {
|
||||
User User
|
||||
ProfilePhoto *Photo
|
||||
PersonalPhoto *Photo
|
||||
FallbackPhoto *Photo
|
||||
About string
|
||||
PhoneCallsAvailable bool
|
||||
PhoneCallsPrivate bool
|
||||
VideoCallsAvailable bool
|
||||
VoiceMessagesForbidden bool
|
||||
ReadDatesPrivate bool
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,6 +101,14 @@ type AccountService interface {
|
|||
GetPassword(ctx context.Context, userID int64) (domain.PasswordSettings, error)
|
||||
}
|
||||
|
||||
// PrivacyService owns account privacy rule storage/evaluation.
|
||||
type PrivacyService interface {
|
||||
GetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, error)
|
||||
SetRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, rules []domain.PrivacyRule) (domain.PrivacyRules, error)
|
||||
AddAllowUser(ctx context.Context, ownerUserID int64, key domain.PrivacyKey, targetUserID int64) (domain.PrivacyRules, bool, error)
|
||||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// HelpService 抽象启动配置与国家区号目录。
|
||||
type HelpService interface {
|
||||
GetAppConfig(ctx context.Context, hash int) (domain.AppConfig, bool, error)
|
||||
|
|
@ -138,6 +146,9 @@ type ContactsService interface {
|
|||
Search(ctx context.Context, userID int64, query string, limit int) (domain.UserSearchResult, error)
|
||||
DeleteContacts(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
|
||||
UpdateContactNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, error)
|
||||
SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photo domain.Photo, date int) (domain.Contact, error)
|
||||
ClearPersonalPhoto(ctx context.Context, userID, contactUserID int64, date int) (domain.Contact, error)
|
||||
PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
GetPeerSettings(ctx context.Context, userID int64, peer domain.Peer) (domain.PeerSettings, error)
|
||||
BlockContact(ctx context.Context, userID, peerUserID int64, date int) (bool, error)
|
||||
UnblockContact(ctx context.Context, userID, peerUserID int64) (bool, error)
|
||||
|
|
@ -307,10 +318,15 @@ type FilesService interface {
|
|||
GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error)
|
||||
GetDocument(ctx context.Context, id int64) (domain.Document, bool, error)
|
||||
UploadProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64, file domain.UploadedFileRef, date int) (domain.Photo, error)
|
||||
UploadProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, file domain.UploadedFileRef, date int) (domain.Photo, error)
|
||||
SetCurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) (domain.Photo, bool, error)
|
||||
SetCurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) (domain.Photo, bool, error)
|
||||
CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (domain.Photo, bool, error)
|
||||
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error)
|
||||
GetProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error)
|
||||
GetProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (photos []domain.Photo, total int, err error)
|
||||
DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) (int, error)
|
||||
DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) (int, error)
|
||||
}
|
||||
|
||||
// LangPackService 抽象客户端语言包查询。
|
||||
|
|
@ -324,6 +340,7 @@ type LangPackService interface {
|
|||
type Deps struct {
|
||||
Auth AuthService
|
||||
Account AccountService
|
||||
Privacy PrivacyService
|
||||
Help HelpService
|
||||
Users UsersService
|
||||
Updates UpdatesService
|
||||
|
|
|
|||
|
|
@ -70,18 +70,33 @@ func (f *fakeFiles) GetDocument(_ context.Context, id int64) (domain.Document, b
|
|||
func (f *fakeFiles) UploadProfilePhoto(context.Context, domain.PeerType, int64, domain.UploadedFileRef, int) (domain.Photo, error) {
|
||||
return domain.Photo{}, nil
|
||||
}
|
||||
func (f *fakeFiles) UploadProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, domain.UploadedFileRef, int) (domain.Photo, error) {
|
||||
return domain.Photo{}, nil
|
||||
}
|
||||
func (f *fakeFiles) SetCurrentProfilePhoto(context.Context, domain.PeerType, int64, int64, int) (domain.Photo, bool, error) {
|
||||
return domain.Photo{}, false, nil
|
||||
}
|
||||
func (f *fakeFiles) SetCurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int64, int) (domain.Photo, bool, error) {
|
||||
return domain.Photo{}, false, nil
|
||||
}
|
||||
func (f *fakeFiles) CurrentProfilePhoto(context.Context, domain.PeerType, int64) (domain.Photo, bool, error) {
|
||||
return domain.Photo{}, false, nil
|
||||
}
|
||||
func (f *fakeFiles) CurrentProfilePhotoKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind) (domain.Photo, bool, error) {
|
||||
return domain.Photo{}, false, nil
|
||||
}
|
||||
func (f *fakeFiles) GetProfilePhotos(context.Context, domain.PeerType, int64, int, int, int64) ([]domain.Photo, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeFiles) GetProfilePhotosKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, int, int, int64) ([]domain.Photo, int, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
func (f *fakeFiles) DeleteProfilePhotos(context.Context, domain.PeerType, int64, []int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (f *fakeFiles) DeleteProfilePhotosKind(context.Context, domain.PeerType, int64, domain.ProfilePhotoKind, []int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func newMediaTestRouter(t *testing.T) (*Router, domain.User, domain.User) {
|
||||
t.Helper()
|
||||
|
|
|
|||
|
|
@ -10,9 +10,13 @@ import (
|
|||
type ContactStore interface {
|
||||
ListByUser(ctx context.Context, userID int64) (domain.ContactList, error)
|
||||
Get(ctx context.Context, userID, contactUserID int64) (domain.Contact, bool, error)
|
||||
GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error)
|
||||
GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error)
|
||||
Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error)
|
||||
UpsertMany(ctx context.Context, userID int64, inputs []domain.ContactInput) ([]domain.Contact, error)
|
||||
UpdateNote(ctx context.Context, userID, contactUserID int64, note string, entities []domain.MessageEntity) (domain.Contact, bool, error)
|
||||
SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error)
|
||||
PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error)
|
||||
Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error)
|
||||
Unblock(ctx context.Context, userID, blockedUserID int64) (bool, error)
|
||||
|
|
|
|||
|
|
@ -39,8 +39,13 @@ type MediaStore interface {
|
|||
|
||||
// 头像历史(owner = user/channel;current = active 中 sort_order 最大者)。
|
||||
AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error
|
||||
AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error
|
||||
CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error)
|
||||
CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error)
|
||||
CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error)
|
||||
CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error)
|
||||
ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) (ids []int64, total int, err error)
|
||||
ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) (ids []int64, total int, err error)
|
||||
DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error)
|
||||
DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,6 +276,52 @@ func (s *ContactStore) Get(_ context.Context, userID, contactUserID int64) (doma
|
|||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetMany(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, contact := range list.Contacts {
|
||||
if _, ok := want[contact.User.ID]; ok {
|
||||
out[contact.User.ID] = cloneContact(contact)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetReverseContacts(_ context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(ownerUserIDs))
|
||||
if userID == 0 || len(ownerUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(ownerUserIDs))
|
||||
for _, id := range ownerUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for ownerID := range want {
|
||||
for _, contact := range s.m[ownerID].Contacts {
|
||||
if contact.User.ID == userID {
|
||||
out[ownerID] = cloneContact(contact)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Upsert(_ context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
contact := domain.Contact{
|
||||
User: domain.User{
|
||||
|
|
@ -366,6 +412,52 @@ func (s *ContactStore) UpdateNote(_ context.Context, userID, contactUserID int64
|
|||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) SetPersonalPhoto(_ context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
||||
_ = date
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
list := s.m[userID]
|
||||
for i := range list.Contacts {
|
||||
if list.Contacts[i].User.ID != contactUserID {
|
||||
continue
|
||||
}
|
||||
list.Contacts[i].User.PhotoID = photoID
|
||||
list.Contacts[i].User.PhotoPersonal = photoID != 0
|
||||
list.Hash = contactListHash(list.Contacts)
|
||||
s.m[userID] = list
|
||||
return cloneContact(list.Contacts[i]), true, nil
|
||||
}
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) PersonalPhotos(_ context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
want := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
if id != 0 {
|
||||
want[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
s.mu.RLock()
|
||||
list := s.m[userID]
|
||||
s.mu.RUnlock()
|
||||
for _, contact := range list.Contacts {
|
||||
if _, ok := want[contact.User.ID]; !ok || contact.User.PhotoID == 0 {
|
||||
continue
|
||||
}
|
||||
out[contact.User.ID] = domain.ProfilePhotoRef{
|
||||
PhotoID: contact.User.PhotoID,
|
||||
DCID: contact.User.PhotoDCID,
|
||||
Stripped: append([]byte(nil), contact.User.PhotoStripped...),
|
||||
Personal: true,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Delete(_ context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
remove := make(map[int64]struct{}, len(contactUserIDs))
|
||||
for _, id := range contactUserIDs {
|
||||
|
|
|
|||
75
internal/store/memory/privacy.go
Normal file
75
internal/store/memory/privacy.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
type privacyStoreKey struct {
|
||||
ownerUserID int64
|
||||
key domain.PrivacyKey
|
||||
}
|
||||
|
||||
// PrivacyStore is an in-memory account privacy rule store for tests/dev mode.
|
||||
type PrivacyStore struct {
|
||||
mu sync.RWMutex
|
||||
rules map[privacyStoreKey]domain.PrivacyRules
|
||||
}
|
||||
|
||||
func NewPrivacyStore() *PrivacyStore {
|
||||
return &PrivacyStore{rules: make(map[privacyStoreKey]domain.PrivacyRules)}
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) GetPrivacyRules(_ context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
|
||||
s.mu.RLock()
|
||||
rules, ok := s.rules[privacyStoreKey{ownerUserID: ownerUserID, key: key}]
|
||||
s.mu.RUnlock()
|
||||
return clonePrivacyRules(rules), ok, nil
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) SetPrivacyRules(_ context.Context, rules domain.PrivacyRules) error {
|
||||
s.mu.Lock()
|
||||
s.rules[privacyStoreKey{ownerUserID: rules.OwnerUserID, key: rules.Key}] = clonePrivacyRules(rules)
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) ListPrivacyRules(_ context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
|
||||
if len(ownerUserIDs) == 0 || len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
owners := make(map[int64]struct{}, len(ownerUserIDs))
|
||||
for _, id := range ownerUserIDs {
|
||||
owners[id] = struct{}{}
|
||||
}
|
||||
keySet := make(map[domain.PrivacyKey]struct{}, len(keys))
|
||||
for _, key := range keys {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]domain.PrivacyRules, 0, len(s.rules))
|
||||
for k, rules := range s.rules {
|
||||
if _, ok := owners[k.ownerUserID]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := keySet[k.key]; !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, clonePrivacyRules(rules))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func clonePrivacyRules(in domain.PrivacyRules) domain.PrivacyRules {
|
||||
out := in
|
||||
out.Rules = make([]domain.PrivacyRule, len(in.Rules))
|
||||
for i, rule := range in.Rules {
|
||||
out.Rules[i] = rule
|
||||
out.Rules[i].UserIDs = append([]int64(nil), rule.UserIDs...)
|
||||
out.Rules[i].ChatIDs = append([]int64(nil), rule.ChatIDs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -59,6 +59,98 @@ func (s *ContactStore) Get(ctx context.Context, userID, contactUserID int64) (do
|
|||
return contact, true, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetMany(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
c.contact_user_id,
|
||||
c.mutual,
|
||||
c.contact_phone,
|
||||
c.contact_first_name,
|
||||
c.contact_last_name,
|
||||
c.note,
|
||||
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
|
||||
u.id,
|
||||
u.access_hash,
|
||||
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
|
||||
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
|
||||
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
|
||||
u.username,
|
||||
u.country_code,
|
||||
u.verified,
|
||||
u.support,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
WHERE c.user_id = $1
|
||||
AND c.contact_user_id = ANY($2::bigint[])
|
||||
`, userID, contactUserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get contacts many: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
contact, err := scanContactRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[contact.User.ID] = contact
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) GetReverseContacts(ctx context.Context, userID int64, ownerUserIDs []int64) (map[int64]domain.Contact, error) {
|
||||
out := make(map[int64]domain.Contact, len(ownerUserIDs))
|
||||
if userID == 0 || len(ownerUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
c.user_id AS owner_user_id,
|
||||
c.mutual,
|
||||
c.contact_phone,
|
||||
c.contact_first_name,
|
||||
c.contact_last_name,
|
||||
c.note,
|
||||
COALESCE(c.note_entities::text, '[]')::text AS note_entities_json,
|
||||
u.id,
|
||||
u.access_hash,
|
||||
COALESCE(NULLIF(c.contact_phone, ''), u.phone)::text AS phone,
|
||||
COALESCE(NULLIF(c.contact_first_name, ''), u.first_name)::text AS first_name,
|
||||
COALESCE(c.contact_last_name, u.last_name)::text AS last_name,
|
||||
u.username,
|
||||
u.country_code,
|
||||
u.verified,
|
||||
u.support,
|
||||
u.last_seen_at
|
||||
FROM contacts c
|
||||
JOIN users u ON u.id = c.contact_user_id
|
||||
WHERE c.contact_user_id = $1
|
||||
AND c.user_id = ANY($2::bigint[])
|
||||
`, userID, ownerUserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get reverse contacts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
ownerID, contact, err := scanReverseContactRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[ownerID] = contact
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Upsert(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
entities, err := encodeMessageEntities(input.NoteEntities)
|
||||
if err != nil {
|
||||
|
|
@ -295,6 +387,70 @@ func (s *ContactStore) UpdateNote(ctx context.Context, userID, contactUserID int
|
|||
return contact, true, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) SetPersonalPhoto(ctx context.Context, userID, contactUserID int64, photoID int64, date int) (domain.Contact, bool, error) {
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
UPDATE contacts
|
||||
SET personal_photo_id = $3,
|
||||
personal_photo_date = CASE WHEN $3::bigint = 0 THEN 0 ELSE $4::int END,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1
|
||||
AND contact_user_id = $2
|
||||
`, userID, contactUserID, photoID, date)
|
||||
if err != nil {
|
||||
return domain.Contact{}, false, fmt.Errorf("set contact personal photo: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.Contact{}, false, nil
|
||||
}
|
||||
contact, found, err := s.Get(ctx, userID, contactUserID)
|
||||
return contact, found, err
|
||||
}
|
||||
|
||||
func (s *ContactStore) PersonalPhotos(ctx context.Context, userID int64, contactUserIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(contactUserIDs))
|
||||
if userID == 0 || len(contactUserIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
c.contact_user_id,
|
||||
c.personal_photo_id,
|
||||
ph.dc_id,
|
||||
ph.sizes::text AS sizes_json
|
||||
FROM contacts c
|
||||
JOIN photos ph ON ph.id = c.personal_photo_id
|
||||
WHERE c.user_id = $1
|
||||
AND c.contact_user_id = ANY($2::bigint[])
|
||||
AND c.personal_photo_id <> 0
|
||||
`, userID, contactUserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list contact personal photos: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var contactUserID, photoID int64
|
||||
var dcID int32
|
||||
var sizesJSON string
|
||||
if err := rows.Scan(&contactUserID, &photoID, &dcID, &sizesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizes, err := decodePhotoSizes(sizesJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[contactUserID] = domain.ProfilePhotoRef{
|
||||
PhotoID: photoID,
|
||||
DCID: int(dcID),
|
||||
Stripped: domain.StrippedFromSizes(sizes),
|
||||
Personal: true,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Delete(ctx context.Context, userID int64, contactUserIDs []int64) (int, error) {
|
||||
if len(contactUserIDs) == 0 {
|
||||
return 0, nil
|
||||
|
|
@ -366,6 +522,107 @@ func contactFromFields(id, accessHash int64, phone, firstName, lastName, usernam
|
|||
}
|
||||
}
|
||||
|
||||
type contactScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanContactRows(row contactScanner) (domain.Contact, error) {
|
||||
var (
|
||||
contactUserID int64
|
||||
mutual bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&contactUserID,
|
||||
&mutual,
|
||||
&contactPhone,
|
||||
&contactFirstName,
|
||||
&contactLastName,
|
||||
¬e,
|
||||
¬eEntitiesJSON,
|
||||
&id,
|
||||
&accessHash,
|
||||
&phone,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&username,
|
||||
&countryCode,
|
||||
&verified,
|
||||
&support,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
entities, err := decodeMessageEntities(noteEntitiesJSON)
|
||||
if err != nil {
|
||||
return domain.Contact{}, err
|
||||
}
|
||||
return contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual), nil
|
||||
}
|
||||
|
||||
func scanReverseContactRows(row contactScanner) (int64, domain.Contact, error) {
|
||||
var (
|
||||
ownerUserID int64
|
||||
mutual bool
|
||||
contactPhone string
|
||||
contactFirstName string
|
||||
contactLastName string
|
||||
note string
|
||||
noteEntitiesJSON string
|
||||
id int64
|
||||
accessHash int64
|
||||
phone string
|
||||
firstName string
|
||||
lastName string
|
||||
username string
|
||||
countryCode string
|
||||
verified bool
|
||||
support bool
|
||||
lastSeenAt int32
|
||||
)
|
||||
if err := row.Scan(
|
||||
&ownerUserID,
|
||||
&mutual,
|
||||
&contactPhone,
|
||||
&contactFirstName,
|
||||
&contactLastName,
|
||||
¬e,
|
||||
¬eEntitiesJSON,
|
||||
&id,
|
||||
&accessHash,
|
||||
&phone,
|
||||
&firstName,
|
||||
&lastName,
|
||||
&username,
|
||||
&countryCode,
|
||||
&verified,
|
||||
&support,
|
||||
&lastSeenAt,
|
||||
); err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
}
|
||||
entities, err := decodeMessageEntities(noteEntitiesJSON)
|
||||
if err != nil {
|
||||
return 0, domain.Contact{}, err
|
||||
}
|
||||
contact := contactFromFields(id, accessHash, phone, firstName, lastName, username, countryCode, verified, support, int(lastSeenAt), contactFirstName, contactLastName, contactPhone, note, entities, mutual)
|
||||
return ownerUserID, contact, nil
|
||||
}
|
||||
|
||||
func (s *ContactStore) Block(ctx context.Context, userID, blockedUserID int64, date int) (bool, error) {
|
||||
if userID == 0 || blockedUserID == 0 || userID == blockedUserID {
|
||||
return false, nil
|
||||
|
|
|
|||
|
|
@ -420,27 +420,44 @@ func (s *MediaStore) CountAvailableReactions(ctx context.Context) (int, error) {
|
|||
// ---- 头像历史 ----
|
||||
|
||||
func (s *MediaStore) AddProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID, photoID int64, date int) error {
|
||||
next, err := s.q.NextProfilePhotoOrder(ctx, sqlcgen.NextProfilePhotoOrderParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
})
|
||||
return s.AddProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoID, date)
|
||||
}
|
||||
|
||||
func (s *MediaStore) AddProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoID int64, date int) error {
|
||||
kind = normalizeProfilePhotoKind(kind)
|
||||
next, err := s.nextProfilePhotoOrder(ctx, ownerType, ownerID, kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.q.AddProfilePhoto(ctx, sqlcgen.AddProfilePhotoParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
PhotoID: photoID,
|
||||
Date: int32(date),
|
||||
SortOrder: next + 1,
|
||||
})
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO profile_photos (owner_peer_type, owner_peer_id, kind, photo_id, date, active, sort_order)
|
||||
VALUES ($1, $2, $3, $4, $5, true, $6)
|
||||
ON CONFLICT (owner_peer_type, owner_peer_id, kind, photo_id) DO UPDATE SET
|
||||
date = EXCLUDED.date,
|
||||
active = true,
|
||||
sort_order = EXCLUDED.sort_order
|
||||
`, string(ownerType), ownerID, string(kind), photoID, date, next+1)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.PeerType, ownerID int64) (int64, bool, error) {
|
||||
id, err := s.q.CurrentProfilePhoto(ctx, sqlcgen.CurrentProfilePhotoParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
})
|
||||
return s.CurrentProfilePhotoKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (s *MediaStore) CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, bool, error) {
|
||||
kind = normalizeProfilePhotoKind(kind)
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT photo_id
|
||||
FROM profile_photos
|
||||
WHERE owner_peer_type = $1
|
||||
AND owner_peer_id = $2
|
||||
AND kind = $3
|
||||
AND active
|
||||
ORDER BY sort_order DESC
|
||||
LIMIT 1
|
||||
`, string(ownerType), ownerID, string(kind))
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false, nil
|
||||
|
|
@ -451,59 +468,156 @@ func (s *MediaStore) CurrentProfilePhoto(ctx context.Context, ownerType domain.P
|
|||
}
|
||||
|
||||
func (s *MediaStore) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return s.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
||||
func (s *MediaStore) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
if len(ownerIDs) == 0 {
|
||||
return map[int64]domain.ProfilePhotoRef{}, nil
|
||||
}
|
||||
rows, err := s.q.CurrentProfilePhotosForOwners(ctx, sqlcgen.CurrentProfilePhotosForOwnersParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerIds: ownerIDs,
|
||||
})
|
||||
kind = normalizeProfilePhotoKind(kind)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT DISTINCT ON (pp.owner_peer_id)
|
||||
pp.owner_peer_id,
|
||||
pp.photo_id,
|
||||
ph.dc_id,
|
||||
ph.sizes::text AS sizes_json
|
||||
FROM profile_photos pp
|
||||
JOIN photos ph ON ph.id = pp.photo_id
|
||||
WHERE pp.owner_peer_type = $1
|
||||
AND pp.owner_peer_id = ANY($2::bigint[])
|
||||
AND pp.kind = $3
|
||||
AND pp.active
|
||||
ORDER BY pp.owner_peer_id, pp.sort_order DESC
|
||||
`, string(ownerType), ownerIDs, string(kind))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(rows))
|
||||
for _, r := range rows {
|
||||
sizes, err := decodePhotoSizes(r.SizesJson)
|
||||
defer rows.Close()
|
||||
out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs))
|
||||
for rows.Next() {
|
||||
var ownerID, photoID int64
|
||||
var dcID int32
|
||||
var sizesJSON string
|
||||
if err := rows.Scan(&ownerID, &photoID, &dcID, &sizesJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sizes, err := decodePhotoSizes(sizesJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[r.OwnerPeerID] = domain.ProfilePhotoRef{
|
||||
PhotoID: r.PhotoID,
|
||||
DCID: int(r.DcID),
|
||||
out[ownerID] = domain.ProfilePhotoRef{
|
||||
PhotoID: photoID,
|
||||
DCID: int(dcID),
|
||||
Stripped: domain.StrippedFromSizes(sizes),
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *MediaStore) ListProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, offset, limit int, maxID int64) ([]int64, int, error) {
|
||||
ids, err := s.q.ListProfilePhotos(ctx, sqlcgen.ListProfilePhotosParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
MaxID: maxID,
|
||||
OffsetCount: int32(offset),
|
||||
LimitCount: int32(limit),
|
||||
})
|
||||
return s.ListProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, offset, limit, maxID)
|
||||
}
|
||||
|
||||
func (s *MediaStore) ListProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, offset, limit int, maxID int64) ([]int64, int, error) {
|
||||
kind = normalizeProfilePhotoKind(kind)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT photo_id
|
||||
FROM profile_photos
|
||||
WHERE owner_peer_type = $1
|
||||
AND owner_peer_id = $2
|
||||
AND kind = $3
|
||||
AND active
|
||||
AND ($4::bigint <= 0 OR photo_id < $4::bigint)
|
||||
ORDER BY sort_order DESC
|
||||
OFFSET $5
|
||||
LIMIT $6
|
||||
`, string(ownerType), ownerID, string(kind), maxID, offset, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
total, err := s.q.CountProfilePhotos(ctx, sqlcgen.CountProfilePhotosParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
})
|
||||
defer rows.Close()
|
||||
ids := make([]int64, 0, limit)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var total int
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT count(*)::int
|
||||
FROM profile_photos
|
||||
WHERE owner_peer_type = $1
|
||||
AND owner_peer_id = $2
|
||||
AND kind = $3
|
||||
AND active
|
||||
`, string(ownerType), ownerID, string(kind)).Scan(&total)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return ids, int(total), nil
|
||||
return ids, total, nil
|
||||
}
|
||||
|
||||
func (s *MediaStore) DeleteProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerID int64, photoIDs []int64) ([]int64, error) {
|
||||
return s.DeleteProfilePhotosKind(ctx, ownerType, ownerID, domain.ProfilePhotoKindProfile, photoIDs)
|
||||
}
|
||||
|
||||
func (s *MediaStore) DeleteProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind, photoIDs []int64) ([]int64, error) {
|
||||
if len(photoIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return s.q.DeactivateProfilePhotos(ctx, sqlcgen.DeactivateProfilePhotosParams{
|
||||
OwnerPeerType: string(ownerType),
|
||||
OwnerPeerID: ownerID,
|
||||
PhotoIds: photoIDs,
|
||||
})
|
||||
kind = normalizeProfilePhotoKind(kind)
|
||||
rows, err := s.db.Query(ctx, `
|
||||
UPDATE profile_photos
|
||||
SET active = false
|
||||
WHERE owner_peer_type = $1
|
||||
AND owner_peer_id = $2
|
||||
AND kind = $3
|
||||
AND photo_id = ANY($4::bigint[])
|
||||
AND active
|
||||
RETURNING photo_id
|
||||
`, string(ownerType), ownerID, string(kind), photoIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
deleted := make([]int64, 0, len(photoIDs))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deleted = append(deleted, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (s *MediaStore) nextProfilePhotoOrder(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (int64, error) {
|
||||
var maxOrder int64
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT COALESCE(MAX(sort_order), 0)::bigint
|
||||
FROM profile_photos
|
||||
WHERE owner_peer_type = $1
|
||||
AND owner_peer_id = $2
|
||||
AND kind = $3
|
||||
`, string(ownerType), ownerID, string(kind)).Scan(&maxOrder)
|
||||
return maxOrder, err
|
||||
}
|
||||
|
||||
func normalizeProfilePhotoKind(kind domain.ProfilePhotoKind) domain.ProfilePhotoKind {
|
||||
if kind == domain.ProfilePhotoKindFallback {
|
||||
return kind
|
||||
}
|
||||
return domain.ProfilePhotoKindProfile
|
||||
}
|
||||
|
|
|
|||
125
internal/store/postgres/privacy.go
Normal file
125
internal/store/postgres/privacy.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
var _ store.PrivacyStore = (*PrivacyStore)(nil)
|
||||
|
||||
// PrivacyStore persists account privacy rules in PostgreSQL.
|
||||
type PrivacyStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
func NewPrivacyStore(db sqlcgen.DBTX) *PrivacyStore {
|
||||
return &PrivacyStore{db: db}
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT rules::text
|
||||
FROM account_privacy_rules
|
||||
WHERE owner_user_id = $1
|
||||
AND privacy_key = $2
|
||||
`, ownerUserID, string(key))
|
||||
var raw string
|
||||
if err := row.Scan(&raw); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.PrivacyRules{}, false, nil
|
||||
}
|
||||
return domain.PrivacyRules{}, false, fmt.Errorf("get privacy rules: %w", err)
|
||||
}
|
||||
rules, err := decodePrivacyRulesJSON(raw)
|
||||
if err != nil {
|
||||
return domain.PrivacyRules{}, false, err
|
||||
}
|
||||
return domain.PrivacyRules{OwnerUserID: ownerUserID, Key: key, Rules: rules}, true, nil
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error {
|
||||
raw, err := json.Marshal(rules.Rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO account_privacy_rules (owner_user_id, privacy_key, rules, updated_at)
|
||||
VALUES ($1, $2, $3::jsonb, NOW())
|
||||
ON CONFLICT (owner_user_id, privacy_key) DO UPDATE SET
|
||||
rules = EXCLUDED.rules,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, rules.OwnerUserID, string(rules.Key), string(raw))
|
||||
if err != nil {
|
||||
return fmt.Errorf("set privacy rules: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PrivacyStore) ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error) {
|
||||
if len(ownerUserIDs) == 0 || len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT owner_user_id, privacy_key, rules::text
|
||||
FROM account_privacy_rules
|
||||
WHERE owner_user_id = ANY($1::bigint[])
|
||||
AND privacy_key = ANY($2::text[])
|
||||
`, ownerUserIDs, privacyKeyStrings(keys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list privacy rules: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.PrivacyRules, 0)
|
||||
for rows.Next() {
|
||||
var ownerUserID int64
|
||||
var key string
|
||||
var raw string
|
||||
if err := rows.Scan(&ownerUserID, &key, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rules, err := decodePrivacyRulesJSON(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, domain.PrivacyRules{
|
||||
OwnerUserID: ownerUserID,
|
||||
Key: domain.PrivacyKey(key),
|
||||
Rules: rules,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func privacyKeyStrings(keys []domain.PrivacyKey) []string {
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, string(key))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodePrivacyRulesJSON(raw string) ([]domain.PrivacyRule, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var rules []domain.PrivacyRule
|
||||
if err := json.Unmarshal([]byte(raw), &rules); err != nil {
|
||||
return nil, fmt.Errorf("decode privacy rules: %w", err)
|
||||
}
|
||||
for i := range rules {
|
||||
rules[i].UserIDs = append([]int64(nil), rules[i].UserIDs...)
|
||||
rules[i].ChatIDs = append([]int64(nil), rules[i].ChatIDs...)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
14
internal/store/privacy.go
Normal file
14
internal/store/privacy.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// PrivacyStore persists account privacy rules by owner user and privacy key.
|
||||
type PrivacyStore interface {
|
||||
GetPrivacyRules(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (domain.PrivacyRules, bool, error)
|
||||
SetPrivacyRules(ctx context.Context, rules domain.PrivacyRules) error
|
||||
ListPrivacyRules(ctx context.Context, ownerUserIDs []int64, keys []domain.PrivacyKey) ([]domain.PrivacyRules, error)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue