admin: add account spam restriction (join/message gate)

Adds a narrower spam sanction alongside the existing account freeze: a
restricted account keeps every existing membership and conversation, but
cannot join new channels/groups (public join or invite link) and cannot
start a new conversation with a non-contact. Reachable both as a standalone
admin action and as a decision on a reported user's moderation case, with
the same idempotent-supersession and appeal wiring freeze already has.
This commit is contained in:
Astra 2026-09-16 14:03:15 +01:00
parent 3ca8ef1a16
commit f33e25af8d
32 changed files with 750 additions and 44 deletions

View file

@ -31,6 +31,13 @@ type AccountFreezeProvider interface {
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
}
// AccountRestrictionProvider returns durable narrower spam-restriction facts
// for a bounded batch. Like AccountFreezeProvider, the projector only exposes
// them to viewers other than the restricted user.
type AccountRestrictionProvider interface {
AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error)
}
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
// 投影里 per-user 3×CanSee 的 N+1。可选实现了它的 evaluatorprivacy.Service会被
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
@ -56,10 +63,11 @@ var privacyProjectionKeys = []domain.PrivacyKey{
// 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
freezes AccountFreezeProvider
contacts store.ContactStore
photos ProfilePhotoProvider
privacy PrivacyEvaluator
freezes AccountFreezeProvider
restrictions AccountRestrictionProvider
}
// Option configures a Projector.
@ -85,6 +93,12 @@ func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
return func(p *Projector) { p.freezes = provider }
}
// WithAccountRestrictionProvider enables viewer-scoped restricted-account
// visibility (the narrower spam restriction).
func WithAccountRestrictionProvider(provider AccountRestrictionProvider) Option {
return func(p *Projector) { p.restrictions = provider }
}
// New creates a user projector.
func New(opts ...Option) *Projector {
p := &Projector{}
@ -100,7 +114,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, p.freezes, viewerUserID, users)
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, p.restrictions, viewerUserID, users)
}
// One applies ForViewer to a single user.
@ -149,6 +163,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
personalRefsByViewer map[int64]map[int64]domain.ProfilePhotoRef
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
restrictions map[int64]domain.AccountRestriction
)
g, gctx := errgroup.WithContext(ctx)
// 1) 共享头像profile/fallback 一次批量,跨全部 viewer 复用。
@ -186,6 +201,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
return err
})
}
if p.restrictions != nil && len(ids) > 0 {
g.Go(func() error {
var err error
restrictions, err = p.restrictions.AccountRestrictions(gctx, ids)
return err
})
}
if err := g.Wait(); err != nil {
return nil, err
}
@ -223,6 +245,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
}
}
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
pj = applyAccountRestrictionProjection(pj, viewer, restrictions[u.ID])
cache[u.ID] = pj
projected[i] = pj
}
@ -363,7 +386,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, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, restrictionsProvider AccountRestrictionProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
if len(users) == 0 {
return users, nil
}
@ -377,6 +400,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
contactsByID map[int64]domain.Contact
visibility map[int64]map[domain.PrivacyKey]bool
freezes map[int64]domain.AccountFreeze
restrictions map[int64]domain.AccountRestriction
)
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
@ -449,6 +473,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
return nil
})
}
if restrictionsProvider != nil && len(ids) > 0 {
g.Go(func() error {
m, err := restrictionsProvider.AccountRestrictions(gctx, ids)
if err != nil {
return err
}
restrictions = m
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
@ -480,6 +514,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
}
}
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
projected = applyAccountRestrictionProjection(projected, viewerUserID, restrictions[u.ID])
cache[u.ID] = projected
out[i] = projected
}
@ -488,6 +523,8 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
// Base users and self users must never retain a viewer-scoped restriction.
// This runs first in the projection chain, so it is the one place that
// establishes the nil baseline; applyAccountRestrictionProjection appends.
user.RestrictionReasons = nil
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
return user
@ -496,6 +533,14 @@ func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze d
return user
}
func applyAccountRestrictionProjection(user domain.User, viewerUserID int64, restriction domain.AccountRestriction) domain.User {
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !restriction.Restricted {
return user
}
user.RestrictionReasons = append(user.RestrictionReasons, domain.AccountRestrictedRestrictionReasons()...)
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