Merge remote-tracking branch 'upstream/main' into merge-gramsrv-0e2fcdf9
This commit is contained in:
commit
b443ff0c73
277 changed files with 30747 additions and 1551 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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。可选:实现了它的 evaluator(privacy.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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue