business: add privacy-aware user projection

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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")
}
}

View file

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

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

View file

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

View file

@ -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 占位)。

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

View file

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

View file

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

View file

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

View file

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

View file

@ -39,8 +39,13 @@ type MediaStore interface {
// 头像历史owner = user/channelcurrent = 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)
}

View file

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

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

View file

@ -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,
&note,
&noteEntitiesJSON,
&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,
&note,
&noteEntitiesJSON,
&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

View file

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

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