fix: sync account freeze peer visibility
This commit is contained in:
parent
d9875b5caa
commit
eba402946a
26 changed files with 1034 additions and 19 deletions
|
|
@ -132,5 +132,8 @@ func cloneUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Service struct {
|
|||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -49,6 +50,10 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -84,6 +89,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -502,6 +502,9 @@ func cloneDialogUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -54,6 +55,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -93,6 +98,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
|
|
@ -57,6 +58,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
|
|
@ -85,6 +90,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,9 @@ func cloneCachedUser(in domain.User) domain.User {
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -262,6 +283,7 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
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
|
||||
}
|
||||
|
|
@ -357,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
|
||||
}
|
||||
|
|
@ -371,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 只写自己那一个变量,组装循环在
|
||||
|
|
@ -433,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
|
||||
}
|
||||
|
|
@ -462,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
|
||||
|
|
|
|||
|
|
@ -119,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 模板不做
|
||||
|
|
@ -243,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +56,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
|
|
@ -77,6 +82,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue