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:
parent
3ca8ef1a16
commit
f33e25af8d
32 changed files with 750 additions and 44 deletions
|
|
@ -28,14 +28,15 @@ type phonePrivacyService interface {
|
|||
|
||||
// Service 提供通讯录查询。
|
||||
type Service struct {
|
||||
contacts store.ContactStore
|
||||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
contacts store.ContactStore
|
||||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||
// while true, Search never returns @marksbot (domain.VerifierBotUserID),
|
||||
// so the account is unreachable for a client that doesn't already know it.
|
||||
|
|
@ -59,6 +60,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
|||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||
return func(s *Service) { s.restrictions = p }
|
||||
}
|
||||
|
||||
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||
// while true, Search filters @marksbot out of every result set.
|
||||
func WithHideThirdPartyVerification(hidden bool) Option {
|
||||
|
|
@ -101,6 +107,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +133,15 @@ func (s *Service) GetContacts(ctx context.Context, userID int64, hash int64) (do
|
|||
return list, false, nil
|
||||
}
|
||||
|
||||
// IsContact reports whether contactUserID is in userID's own contact list.
|
||||
func (s *Service) IsContact(ctx context.Context, userID, contactUserID int64) (bool, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || contactUserID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
_, found, err := s.contacts.Get(ctx, userID, contactUserID)
|
||||
return found, err
|
||||
}
|
||||
|
||||
func (s *Service) AddContact(ctx context.Context, userID int64, input domain.ContactInput) (domain.Contact, error) {
|
||||
if s == nil || s.contacts == nil || userID == 0 || input.ContactUserID == 0 || input.ContactUserID == userID {
|
||||
return domain.Contact{}, ErrContactIDInvalid
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ type Service struct {
|
|||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -63,6 +64,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
|||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||
return func(s *Service) { s.restrictions = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -132,6 +138,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ import (
|
|||
|
||||
// Service 提供消息历史、搜索与已读业务。
|
||||
type Service struct {
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
messages store.MessageStore
|
||||
dialogs store.DialogStore
|
||||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
// viewerProjectionComplete is true only when every viewer-scoped user
|
||||
// overlay used by the shared RPC Users service is configured here too.
|
||||
// A partially configured service may still project the dependencies it has,
|
||||
|
|
@ -67,6 +68,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
|||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||
return func(s *Service) { s.restrictions = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
|
|
@ -96,6 +102,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||
)
|
||||
s.viewerProjectionComplete = s.contacts != nil && s.photos != nil && s.privacy != nil && s.freezes != nil
|
||||
return s
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import (
|
|||
|
||||
type moderationAdminActions interface {
|
||||
SetAccountFrozen(ctx context.Context, req admin.SetAccountFrozenRequest) (admin.CommandResult, error)
|
||||
SetAccountRestricted(ctx context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error)
|
||||
SetUserFlags(ctx context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error)
|
||||
SetChannelFlags(ctx context.Context, req admin.SetChannelFlagsRequest) (admin.CommandResult, error)
|
||||
DeletePrivateMessages(ctx context.Context, req admin.DeletePrivateMessagesRequest) (admin.CommandResult, error)
|
||||
|
|
@ -99,6 +100,10 @@ type freezeAccountActionPayload struct {
|
|||
AppealURL string `json:"appeal_url,omitempty"`
|
||||
}
|
||||
|
||||
type restrictAccountActionPayload struct {
|
||||
Until time.Time `json:"until,omitempty"`
|
||||
}
|
||||
|
||||
type deletePrivateMessageActionPayload struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
IDs []int `json:"ids"`
|
||||
|
|
@ -181,6 +186,23 @@ func (e *ActionExecutor) Execute(ctx context.Context, detail domain.ModerationCa
|
|||
Until: payload.Until, AppealURL: payload.AppealURL,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload restrictAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
restricted := action.Kind == domain.ModerationActionRestrictAccount
|
||||
if !restricted && !payload.Until.IsZero() {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
_, err := e.admin.SetAccountRestricted(ctx, admin.SetAccountRestrictedRequest{
|
||||
CommandMeta: meta, UserID: detail.Case.Target.ID, Restricted: restricted,
|
||||
Until: payload.Until,
|
||||
})
|
||||
return err
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
if e.admin == nil || detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
|
|
@ -252,6 +274,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
|||
seen := make(map[domain.ModerationActionKind]struct{}, len(actions))
|
||||
flagActions := 0
|
||||
freezeActions := 0
|
||||
restrictActions := 0
|
||||
hasDeleteAccount := false
|
||||
for _, action := range actions {
|
||||
if _, duplicate := seen[action.Kind]; duplicate {
|
||||
|
|
@ -278,6 +301,18 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
|||
(!payload.Until.IsZero() || payload.AppealURL != "") {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
case domain.ModerationActionRestrictAccount, domain.ModerationActionUnrestrictAccount:
|
||||
restrictActions++
|
||||
if detail.Case.Target.Type != domain.PeerTypeUser {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
var payload restrictAccountActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if action.Kind == domain.ModerationActionUnrestrictAccount && !payload.Until.IsZero() {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
case domain.ModerationActionDeletePrivateMessage:
|
||||
var payload deletePrivateMessageActionPayload
|
||||
if err := decodeStrictActionPayload(action.Payload, &payload); err != nil {
|
||||
|
|
@ -311,7 +346,7 @@ func (s *Service) validateDecisionActions(ctx context.Context, detail domain.Mod
|
|||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
}
|
||||
if flagActions > 1 || freezeActions > 1 ||
|
||||
if flagActions > 1 || freezeActions > 1 || restrictActions > 1 ||
|
||||
(hasDeleteAccount && len(actions) != 1) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,9 @@ import (
|
|||
)
|
||||
|
||||
type captureModerationAdmin struct {
|
||||
userFlags []admin.SetUserFlagsRequest
|
||||
frozen []admin.SetAccountFrozenRequest
|
||||
userFlags []admin.SetUserFlagsRequest
|
||||
frozen []admin.SetAccountFrozenRequest
|
||||
restricted []admin.SetAccountRestrictedRequest
|
||||
}
|
||||
|
||||
type captureModerationAccountDeleter struct {
|
||||
|
|
@ -42,6 +43,11 @@ func (a *captureModerationAdmin) SetAccountFrozen(_ context.Context, req admin.S
|
|||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetAccountRestricted(_ context.Context, req admin.SetAccountRestrictedRequest) (admin.CommandResult, error) {
|
||||
a.restricted = append(a.restricted, req)
|
||||
return admin.CommandResult{}, nil
|
||||
}
|
||||
|
||||
func (a *captureModerationAdmin) SetUserFlags(_ context.Context, req admin.SetUserFlagsRequest) (admin.CommandResult, error) {
|
||||
a.userFlags = append(a.userFlags, req)
|
||||
return admin.CommandResult{}, nil
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ func (s *Service) ReviewAppeal(ctx context.Context, request domain.ModerationDec
|
|||
func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []domain.ModerationActionDraft) error {
|
||||
history := append([]domain.ModerationAction(nil), detail.Actions...)
|
||||
sort.Slice(history, func(i, j int) bool { return history[i].ID < history[j].ID })
|
||||
var flagsActive, freezeActive, irreversible bool
|
||||
var flagsActive, freezeActive, restrictActive, irreversible bool
|
||||
for _, action := range history {
|
||||
if action.Status != domain.ModerationActionSucceeded {
|
||||
continue
|
||||
|
|
@ -148,6 +148,10 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d
|
|||
freezeActive = true
|
||||
case domain.ModerationActionUnfreezeAccount:
|
||||
freezeActive = false
|
||||
case domain.ModerationActionRestrictAccount:
|
||||
restrictActive = true
|
||||
case domain.ModerationActionUnrestrictAccount:
|
||||
restrictActive = false
|
||||
case domain.ModerationActionDeletePrivateMessage,
|
||||
domain.ModerationActionDeleteChannelMessage,
|
||||
domain.ModerationActionDeleteAccount:
|
||||
|
|
@ -157,13 +161,16 @@ func validateAppealRemedyActions(detail domain.ModerationCaseDetail, actions []d
|
|||
if irreversible {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
expected := make(map[domain.ModerationActionKind]bool, 2)
|
||||
expected := make(map[domain.ModerationActionKind]bool, 3)
|
||||
if flagsActive {
|
||||
expected[domain.ModerationActionClearPeerFlags] = true
|
||||
}
|
||||
if freezeActive {
|
||||
expected[domain.ModerationActionUnfreezeAccount] = true
|
||||
}
|
||||
if restrictActive {
|
||||
expected[domain.ModerationActionUnrestrictAccount] = true
|
||||
}
|
||||
if len(actions) != len(expected) {
|
||||
return domain.ErrModerationActionInvalid
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,13 +28,14 @@ type ProfilePhotoProvider = userprojection.ProfilePhotoProvider
|
|||
|
||||
// Service 提供用户查询。
|
||||
type Service struct {
|
||||
users store.UserStore
|
||||
cache store.UserCache
|
||||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
users store.UserStore
|
||||
cache store.UserCache
|
||||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
restrictions userprojection.AccountRestrictionProvider
|
||||
projector *userprojection.Projector
|
||||
// hideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||
// while true, ResolveUsername never resolves @marksbot
|
||||
// (domain.VerifierBotUserID), so a client cannot discover it by username.
|
||||
|
|
@ -97,6 +98,11 @@ func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
|||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithAccountRestrictionProvider injects the account spam-restriction reader.
|
||||
func WithAccountRestrictionProvider(p userprojection.AccountRestrictionProvider) Option {
|
||||
return func(s *Service) { s.restrictions = p }
|
||||
}
|
||||
|
||||
// WithHideThirdPartyVerification mirrors config.HideThirdPartyVerification:
|
||||
// while true, ResolveUsername treats @marksbot as not found.
|
||||
func WithHideThirdPartyVerification(hidden bool) Option {
|
||||
|
|
@ -135,6 +141,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
userprojection.WithAccountRestrictionProvider(s.restrictions),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue