Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9

This commit is contained in:
onysd 2026-07-24 17:15:53 +03:00
commit b443ff0c73
277 changed files with 30747 additions and 1551 deletions

View file

@ -451,6 +451,12 @@ func cloneCachedUser(in domain.User) domain.User {
if in.PhotoStripped != nil {
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
}
if in.ContactNoteEntities != nil {
in.ContactNoteEntities = append([]domain.MessageEntity(nil), in.ContactNoteEntities...)
}
if in.RestrictionReasons != nil {
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
}
return in
}

View file

@ -116,7 +116,13 @@ func (s *countingContactStore) SetPersonalPhoto(ctx context.Context, userID, con
func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
ctx := context.Background()
base := memory.NewContactStore()
if _, err := base.Upsert(ctx, 1, domain.ContactInput{ContactUserID: 2, FirstName: "Alice", Phone: "111"}); err != nil {
if _, err := base.Upsert(ctx, 1, domain.ContactInput{
ContactUserID: 2,
FirstName: "Alice",
Phone: "111",
Note: "private note",
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
counting := &countingContactStore{ContactStore: base}
@ -126,15 +132,16 @@ func TestCachedContactStoreCachesProjectionReads(t *testing.T) {
if err != nil {
t.Fatalf("get many first: %v", err)
}
if first[2].FirstName != "Alice" {
t.Fatalf("first contact = %+v, want Alice", first[2])
if first[2].FirstName != "Alice" || first[2].Note != "private note" || len(first[2].NoteEntities) != 1 {
t.Fatalf("first contact = %+v, want Alice with private note", first[2])
}
first[2].NoteEntities[0].Length = 99
second, err := cached.GetMany(ctx, 1, []int64{2, 3})
if err != nil {
t.Fatalf("get many second: %v", err)
}
if second[2].FirstName != "Alice" {
t.Fatalf("second contact = %+v, want Alice", second[2])
if second[2].FirstName != "Alice" || second[2].Note != "private note" || len(second[2].NoteEntities) != 1 || second[2].NoteEntities[0].Length != 7 {
t.Fatalf("second contact = %+v, want isolated cached Alice note", second[2])
}
if counting.listCalls != 1 {
t.Fatalf("ListByUser calls = %d, want 1 account snapshot load", counting.listCalls)

View file

@ -24,6 +24,12 @@ type PrivacyEvaluator interface {
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
}
// AccountFreezeProvider returns durable account freeze facts for a bounded
// batch. The projector only exposes them to viewers other than the frozen user.
type AccountFreezeProvider interface {
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
}
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
// 投影里 per-user 3×CanSee 的 N+1。可选实现了它的 evaluatorprivacy.Service会被
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
@ -52,6 +58,7 @@ type Projector struct {
contacts store.ContactStore
photos ProfilePhotoProvider
privacy PrivacyEvaluator
freezes AccountFreezeProvider
}
// Option configures a Projector.
@ -72,6 +79,11 @@ func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
return func(p *Projector) { p.privacy = privacy }
}
// WithAccountFreezeProvider enables viewer-scoped frozen-account visibility.
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
return func(p *Projector) { p.freezes = provider }
}
// New creates a user projector.
func New(opts ...Option) *Projector {
p := &Projector{}
@ -87,7 +99,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d
if p == nil {
return users, nil
}
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users)
}
// One applies ForViewer to a single user.
@ -136,6 +148,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
fallbackRefs map[int64]domain.ProfilePhotoRef
contactsByViewer map[int64]map[int64]domain.Contact
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
)
g, gctx := errgroup.WithContext(ctx)
// 1) 共享头像profile/fallback 一次批量,跨全部 viewer 复用personal photo v1 跳过(见 doc
@ -159,6 +172,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
return err
})
}
if p.freezes != nil && len(ids) > 0 {
g.Go(func() error {
var err error
freezes, err = p.freezes.AccountFreezes(gctx, ids)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
@ -194,6 +214,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
return nil, perr
}
}
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
cache[u.ID] = pj
projected[i] = pj
}
@ -260,6 +281,10 @@ func cloneUsers(users []domain.User) []domain.User {
}
out := make([]domain.User, len(users))
copy(out, users)
for i := range out {
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
}
return out
}
@ -354,7 +379,7 @@ 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) {
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
if len(users) == 0 {
return users, nil
}
@ -368,6 +393,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
personalRefs = map[int64]domain.ProfilePhotoRef{}
contactsByID map[int64]domain.Contact
visibility map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
)
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
@ -430,6 +456,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
visibility = v
return nil
})
if freezesProvider != nil && len(ids) > 0 {
g.Go(func() error {
m, err := freezesProvider.AccountFreezes(gctx, ids)
if err != nil {
return err
}
freezes = m
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
@ -459,12 +495,23 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
return nil, err
}
}
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
cache[u.ID] = projected
out[i] = projected
}
return out, nil
}
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
// Base users and self users must never retain a viewer-scoped restriction.
user.RestrictionReasons = nil
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
return user
}
user.RestrictionReasons = domain.AccountFrozenRestrictionReasons()
return user
}
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
if privacy == nil || viewerUserID == 0 {
return nil, nil
@ -499,30 +546,7 @@ func projectOne(ctx context.Context, contacts store.ContactStore, viewerUserID i
if err != nil {
return domain.User{}, err
}
if !found {
user.Phone = ""
user.Contact = false
user.Mutual = false
user.CloseFriend = false
return user, nil
}
projected := user
projected.Contact = true
projected.Mutual = contact.Mutual || contact.User.Mutual
projected.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
if contact.User.Phone != "" {
projected.Phone = contact.User.Phone
} else {
projected.Phone = contact.Phone
}
if contact.User.FirstName != "" || contact.User.LastName != "" {
projected.FirstName = contact.User.FirstName
projected.LastName = contact.User.LastName
} else if contact.FirstName != "" || contact.LastName != "" {
projected.FirstName = contact.FirstName
projected.LastName = contact.LastName
}
return projected, nil
return applyContactProjection(user, contact, found), nil
}
func uniqueUserIDs(users []domain.User) []int64 {
@ -575,11 +599,15 @@ func applyContactProjection(user domain.User, contact domain.Contact, found bool
user.Contact = false
user.Mutual = false
user.CloseFriend = false
user.ContactNote = ""
user.ContactNoteEntities = nil
return user
}
user.Contact = true
user.Mutual = contact.Mutual || contact.User.Mutual
user.CloseFriend = contact.CloseFriend || contact.User.CloseFriend
user.ContactNote = contact.Note
user.ContactNoteEntities = append([]domain.MessageEntity(nil), contact.NoteEntities...)
if contact.User.Phone != "" {
user.Phone = contact.User.Phone
} else {

View file

@ -21,6 +21,8 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
Phone: "1111",
FirstName: "Alice",
LastName: "Contact",
Note: "private note",
NoteEntities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 0, Length: 7}},
}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
@ -47,12 +49,15 @@ func TestProjectorCombinesProfilePhotosAndViewerContacts(t *testing.T) {
if friend.FirstName != "Alice" || friend.LastName != "Contact" || friend.Phone != "1111" || !friend.Contact {
t.Fatalf("friend projection = %+v, want contact name/phone", friend)
}
if friend.ContactNote != "private note" || len(friend.ContactNoteEntities) != 1 || friend.ContactNoteEntities[0].Type != domain.MessageEntityBold {
t.Fatalf("friend contact note = %q %+v, want owner-scoped note", friend.ContactNote, friend.ContactNoteEntities)
}
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.Phone != "" || stranger.Contact || stranger.ContactNote != "" || len(stranger.ContactNoteEntities) != 0 {
t.Fatalf("stranger projection = %+v, want hidden phone and no contact note", stranger)
}
if stranger.PhotoID != 9002 || stranger.PhotoDCID != 3 {
t.Fatalf("stranger photo = id %d dc %d, want 9002/3", stranger.PhotoID, stranger.PhotoDCID)
@ -114,6 +119,64 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
}
}
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
ctx := context.Background()
const (
frozenUserID = int64(4001)
otherViewer = int64(4002)
)
freezes := &fakeAccountFreezes{items: map[int64]domain.AccountFreeze{
frozenUserID: {UserID: frozenUserID, Frozen: true, Version: 3},
}}
projector := New(WithAccountFreezeProvider(freezes))
base := []domain.User{{
ID: frozenUserID,
FirstName: "Frozen",
// Viewer-scoped fields must never be trusted from a reused base object.
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "stale", Text: "stale"}},
}}
otherView, err := projector.ForViewer(ctx, otherViewer, base)
if err != nil {
t.Fatalf("ForViewer(other): %v", err)
}
got := projectionUser(t, otherView, frozenUserID)
if !reflect.DeepEqual(got.RestrictionReasons, domain.AccountFrozenRestrictionReasons()) {
t.Fatalf("other-view restriction = %+v, want frozen restriction", got.RestrictionReasons)
}
if base[0].RestrictionReasons[0].Reason != "stale" {
t.Fatalf("projection mutated base user: %+v", base[0])
}
selfView, err := projector.ForViewer(ctx, frozenUserID, base)
if err != nil {
t.Fatalf("ForViewer(self): %v", err)
}
if reasons := projectionUser(t, selfView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("self-view restriction = %+v, want none", reasons)
}
batch, err := projector.ForViewers(ctx, []int64{otherViewer, frozenUserID}, base)
if err != nil {
t.Fatalf("ForViewers: %v", err)
}
if reasons := projectionUser(t, batch[otherViewer], frozenUserID).RestrictionReasons; !reflect.DeepEqual(reasons, domain.AccountFrozenRestrictionReasons()) {
t.Fatalf("batch other-view restriction = %+v", reasons)
}
if reasons := projectionUser(t, batch[frozenUserID], frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("batch self-view restriction = %+v, want none", reasons)
}
freezes.items = nil
unfrozenView, err := projector.ForViewer(ctx, otherViewer, otherView)
if err != nil {
t.Fatalf("ForViewer(after unfreeze): %v", err)
}
if reasons := projectionUser(t, unfrozenView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
t.Fatalf("unfrozen projection retained restriction = %+v", reasons)
}
}
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网ForViewers(viewers, users)
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**v1 模板不做
@ -238,6 +301,20 @@ type fakeProfilePhotos struct {
fallback map[int64]domain.ProfilePhotoRef
}
type fakeAccountFreezes struct {
items map[int64]domain.AccountFreeze
}
func (f *fakeAccountFreezes) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
out := make(map[int64]domain.AccountFreeze)
for _, id := range ids {
if freeze, ok := f.items[id]; ok {
out[id] = freeze
}
}
return out, nil
}
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)
}