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

@ -14,14 +14,21 @@ type accountFreezeFact struct {
found bool
}
type accountRestrictionFact struct {
value domain.AccountRestriction
found bool
}
// DurableUserProjectionFacts caches only viewer-independent durable overlays.
// Contact/privacy/presence decisions remain outside and are evaluated after
// these facts are loaded.
type DurableUserProjectionFacts struct {
freezes AccountFreezeProvider
versions store.ReadModelVersionStore
freezes AccountFreezeProvider
restrictions AccountRestrictionProvider
versions store.ReadModelVersionStore
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
freezeCache *readmodelcache.Cache[int64, accountFreezeFact]
restrictionCache *readmodelcache.Cache[int64, accountRestrictionFact]
}
func NewDurableUserProjectionFacts(
@ -29,12 +36,20 @@ func NewDurableUserProjectionFacts(
versions store.ReadModelVersionStore,
maxEntries int,
) *DurableUserProjectionFacts {
// freezes optionally also implements AccountRestrictionProvider (the
// production admin.Service does); callers that only need freeze facts,
// such as tests, are unaffected.
restrictions, _ := freezes.(AccountRestrictionProvider)
return &DurableUserProjectionFacts{
freezes: freezes,
versions: versions,
freezes: freezes,
restrictions: restrictions,
versions: versions,
freezeCache: readmodelcache.New[int64, accountFreezeFact](readmodelcache.Config[int64, accountFreezeFact]{
MaxEntries: maxEntries,
}),
restrictionCache: readmodelcache.New[int64, accountRestrictionFact](readmodelcache.Config[int64, accountRestrictionFact]{
MaxEntries: maxEntries,
}),
}
}
@ -94,6 +109,62 @@ func (f *DurableUserProjectionFacts) AccountFreeze(ctx context.Context, userID i
return value, found, nil
}
func (f *DurableUserProjectionFacts) AccountRestrictions(ctx context.Context, userIDs []int64) (map[int64]domain.AccountRestriction, error) {
out := make(map[int64]domain.AccountRestriction)
ids := uniqueDurableFactUserIDs(userIDs)
if f == nil || f.restrictions == nil || len(ids) == 0 {
return out, nil
}
// Reuses the same user_visibility read model version as freeze facts:
// SetAccountRestriction bumps it on write, so a shared hash keeps both
// caches correctly invalidated without a separate model.
hashes, err := f.factHashes(ctx, readmodel.ModelUserVisibility, ids)
if err != nil {
return nil, err
}
loaded, err := f.restrictionCache.GetOrLoadBatch(ctx, ids,
func(userID int64) (int64, bool) {
hash := hashes[userID]
return hash, f.versions != nil && hash != 0
},
func(ctx context.Context, missing []int64) (map[int64]accountRestrictionFact, error) {
values, err := f.restrictions.AccountRestrictions(ctx, missing)
if err != nil {
return nil, err
}
entries := make(map[int64]accountRestrictionFact, len(missing))
for _, userID := range missing {
entry := accountRestrictionFact{}
if value, ok := values[userID]; ok {
entry = accountRestrictionFact{value: value, found: true}
}
entries[userID] = entry
}
return entries, nil
})
if err != nil {
return nil, err
}
for userID, entry := range loaded {
if entry.found {
out[userID] = entry.value
}
}
return out, nil
}
func (f *DurableUserProjectionFacts) AccountRestriction(ctx context.Context, userID int64) (domain.AccountRestriction, bool, error) {
if userID == 0 {
return domain.AccountRestriction{}, false, nil
}
items, err := f.AccountRestrictions(ctx, []int64{userID})
if err != nil {
return domain.AccountRestriction{}, false, err
}
value, found := items[userID]
return value, found, nil
}
func (f *DurableUserProjectionFacts) factHashes(ctx context.Context, model string, userIDs []int64) (map[int64]int64, error) {
out := make(map[int64]int64, len(userIDs))
if f == nil || f.versions == nil {
@ -119,9 +190,16 @@ func (f *DurableUserProjectionFacts) InvalidateAccountFreezeFact(userID int64) {
}
}
func (f *DurableUserProjectionFacts) InvalidateAccountRestrictionFact(userID int64) {
if f != nil && userID != 0 {
f.restrictionCache.Invalidate(userID)
}
}
func (f *DurableUserProjectionFacts) FlushUserProjectionFactReadModel() {
if f != nil {
f.freezeCache.Flush()
f.restrictionCache.Flush()
}
}

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。可选:实现了它的 evaluator(privacy.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