From eba402946a8c2acea23df324e2ab66c7bf41b85d Mon Sep 17 00:00:00 2001 From: A Date: Wed, 22 Jul 2026 02:03:00 +0800 Subject: [PATCH] fix: sync account freeze peer visibility --- cmd/telesrv/main.go | 7 +- .../0131_account_freeze_visibility.down.sql | 5 + .../0131_account_freeze_visibility.up.sql | 37 ++++ internal/admin/service.go | 101 +++++++++++ internal/admin/service_test.go | 63 ++++++- internal/app/contacts/read_model_cache.go | 3 + internal/app/contacts/service.go | 6 + internal/app/dialogs/read_model_cache.go | 3 + internal/app/dialogs/service.go | 6 + internal/app/messages/service.go | 6 + internal/app/userprojection/contact_cache.go | 3 + internal/app/userprojection/projection.go | 48 +++++- .../app/userprojection/projection_test.go | 72 ++++++++ internal/app/users/service.go | 6 + internal/domain/admin.go | 13 ++ internal/domain/user.go | 21 +++ internal/rpc/account_freeze_worker.go | 107 ++++++++++++ internal/rpc/account_freeze_worker_test.go | 136 +++++++++++++++ internal/rpc/admin_hooks.go | 17 ++ internal/rpc/convert_users.go | 24 +++ .../rpc/convert_users_restriction_test.go | 62 +++++++ internal/rpc/router.go | 3 +- internal/store/postgres/admin.go | 163 +++++++++++++++++- .../postgres/admin_freeze_integration_test.go | 96 ++++++++++- .../store/postgres/read_model_listener.go | 9 + .../postgres/story_peer_read_model_test.go | 36 ++++ 26 files changed, 1034 insertions(+), 19 deletions(-) create mode 100644 deploy/migrations/0131_account_freeze_visibility.down.sql create mode 100644 deploy/migrations/0131_account_freeze_visibility.up.sql create mode 100644 internal/rpc/account_freeze_worker.go create mode 100644 internal/rpc/account_freeze_worker_test.go create mode 100644 internal/rpc/convert_users_restriction_test.go diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index d5efb734..35556cab 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -550,6 +550,7 @@ func run(logger *zap.Logger) error { contactsService := contacts.NewService(contactStore, userStore).Configure( contacts.WithPhotoProvider(cachedPhotos), contacts.WithPrivacyEvaluator(privacyService), + contacts.WithAccountFreezeProvider(adminService), contacts.WithReadModelVersions(readModelVersionStore), ) if seeded, err := langPackService.SeedDirectory(ctx, cfg.LangPackSeedDir); err != nil { @@ -751,13 +752,14 @@ func run(logger *zap.Logger) error { passkeyapp.WithAllowedOrigins(cfg.PasskeyAllowedOrigins)) // 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。 themeService := themesapp.NewService(postgres.NewThemeStore(pool)) - usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService)) + usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService)) aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...) botsService.SetAIChatGenerator(aiComposeService) dialogsService := dialogs.NewService(dialogStore, channelStore).Configure( dialogs.WithContactStore(contactStore), dialogs.WithPhotoProvider(cachedPhotos), dialogs.WithPrivacyEvaluator(privacyService), + dialogs.WithAccountFreezeProvider(adminService), dialogs.WithPremiumChecker(usersService.PremiumActive), dialogs.WithReadModelVersions(readModelVersionStore), ) @@ -782,6 +784,7 @@ func run(logger *zap.Logger) error { messageapp.WithContactStore(contactStore), messageapp.WithPhotoProvider(cachedPhotos), messageapp.WithPrivacyEvaluator(privacyService), + messageapp.WithAccountFreezeProvider(adminService), messageapp.WithReadModelVersions(readModelVersionStore), messageapp.WithBotResponder(botsService), messageapp.WithSendPermissionChecker(adminService), @@ -911,6 +914,7 @@ func run(logger *zap.Logger) error { Stars: starsService, StarsNotifier: router, UserNotifier: router, + FreezeNotifier: router, Channels: channelsService, ChannelNotifier: router, Messages: messagesService, @@ -938,6 +942,7 @@ func run(logger *zap.Logger) error { go activeSessions.RunPendingSweeper(ctx, time.Minute) go router.RunPremiumSweeper(ctx, cfg.PremiumSweepInterval, cfg.PremiumSweepBatch) go router.RunAccountLifecycle(ctx, time.Minute, 500) + go router.RunAccountFreezeNotifications(ctx, time.Minute, 500) if telegramLoginService != nil { go runTelegramLoginRetention(ctx, telegramLoginService, cfg.TelegramLoginRetention, cfg.TelegramLoginSweepInterval, cfg.TelegramLoginSweepBatch, logger.Named("telegram-login-retention")) } diff --git a/deploy/migrations/0131_account_freeze_visibility.down.sql b/deploy/migrations/0131_account_freeze_visibility.down.sql new file mode 100644 index 00000000..fd2eaf81 --- /dev/null +++ b/deploy/migrations/0131_account_freeze_visibility.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS public.account_freeze_notifications; + +ALTER TABLE public.account_restrictions + DROP CONSTRAINT IF EXISTS account_restrictions_version_check, + DROP COLUMN IF EXISTS version; diff --git a/deploy/migrations/0131_account_freeze_visibility.up.sql b/deploy/migrations/0131_account_freeze_visibility.up.sql new file mode 100644 index 00000000..60993ff5 --- /dev/null +++ b/deploy/migrations/0131_account_freeze_visibility.up.sql @@ -0,0 +1,37 @@ +-- A freeze/unfreeze is a viewer-visible user projection change. Version the +-- durable fact so a claimed old nudge can never acknowledge a newer state. +ALTER TABLE public.account_restrictions + ADD COLUMN version bigint DEFAULT 1 NOT NULL, + ADD CONSTRAINT account_restrictions_version_check CHECK (version > 0); + +-- updateUser has no pts. This coalesced queue is only a crash-safe online +-- nudge; offline clients reconstruct the current restriction from the +-- authoritative account_restrictions row during normal user hydration. +CREATE TABLE public.account_freeze_notifications ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + target_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + frozen_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + version bigint NOT NULL, + frozen boolean NOT NULL, + status text DEFAULT 'pending' NOT NULL, + attempts integer DEFAULT 0 NOT NULL, + next_attempt_at timestamp with time zone DEFAULT now() NOT NULL, + lease_until timestamp with time zone, + last_error text DEFAULT '' NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT account_freeze_notifications_status_check + CHECK (status IN ('pending', 'dispatching', 'delivered')), + CONSTRAINT account_freeze_notifications_attempts_check CHECK (attempts >= 0), + CONSTRAINT account_freeze_notifications_version_check CHECK (version > 0), + CONSTRAINT account_freeze_notifications_not_self_check CHECK (target_user_id <> frozen_user_id), + UNIQUE (target_user_id, frozen_user_id) +); + +CREATE INDEX account_freeze_notifications_ready_idx + ON public.account_freeze_notifications(next_attempt_at, id) + WHERE status = 'pending'; + +CREATE INDEX account_freeze_notifications_lease_idx + ON public.account_freeze_notifications(lease_until, id) + WHERE status = 'dispatching'; diff --git a/internal/admin/service.go b/internal/admin/service.go index 386f1591..356c02b2 100644 --- a/internal/admin/service.go +++ b/internal/admin/service.go @@ -52,6 +52,15 @@ type RestrictionStore interface { SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) } +type accountFreezeBatchStore interface { + GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) +} + +type accountFreezeNotificationStore interface { + ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) + CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error +} + type AuthService interface { ListAuthorizations(ctx context.Context, userID int64) ([]domain.Authorization, error) ResetAuthorization(ctx context.Context, userID, hash int64) (domain.Authorization, bool, error) @@ -80,6 +89,10 @@ type UserNotifier interface { NotifyUserChanged(ctx context.Context, u domain.User) error } +type AccountFreezeNotifier interface { + NotifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error +} + type ChannelsService interface { GetChannelByID(ctx context.Context, channelID int64) (domain.Channel, error) SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error) @@ -123,6 +136,7 @@ type Dependencies struct { Stars StarsService StarsNotifier StarsNotifier UserNotifier UserNotifier + FreezeNotifier AccountFreezeNotifier Channels ChannelsService ChannelNotifier ChannelNotifier Messages MessagesService @@ -140,6 +154,7 @@ type Service struct { stars StarsService starsNotifier StarsNotifier userNotifier UserNotifier + freezeNotifier AccountFreezeNotifier channels ChannelsService channelNotifier ChannelNotifier messages MessagesService @@ -178,6 +193,9 @@ func (s *Service) Configure(deps Dependencies) *Service { if deps.UserNotifier != nil { s.userNotifier = deps.UserNotifier } + if deps.FreezeNotifier != nil { + s.freezeNotifier = deps.FreezeNotifier + } if deps.Channels != nil { s.channels = deps.Channels } @@ -373,6 +391,78 @@ func (s *Service) AccountFreeze(ctx context.Context, userID int64) (domain.Accou return freeze, true, nil } +// AccountFreezes is the bounded-query projection API used by user hydration. +// Production stores use array batches; lightweight test stores keep the exact +// same semantics through the single-row fallback. +func (s *Service) AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) { + out := make(map[int64]domain.AccountFreeze) + if s == nil || s.restrictions == nil || len(userIDs) == 0 { + return out, nil + } + ids := uniqueFreezeUserIDs(userIDs) + if batch, ok := s.restrictions.(accountFreezeBatchStore); ok { + const batchSize = 1000 + for start := 0; start < len(ids); start += batchSize { + end := min(start+batchSize, len(ids)) + items, err := batch.GetAccountFreezes(ctx, ids[start:end]) + if err != nil { + return nil, err + } + for id, freeze := range items { + if err := validateAccountFreeze(freeze); err != nil { + return nil, fmt.Errorf("invalid durable account freeze for user %d: %w", id, err) + } + if freeze.Frozen { + out[id] = freeze + } + } + } + return out, nil + } + for _, id := range ids { + freeze, found, err := s.AccountFreeze(ctx, id) + if err != nil { + return nil, err + } + if found && freeze.Frozen { + out[id] = freeze + } + } + return out, nil +} + +func uniqueFreezeUserIDs(userIDs []int64) []int64 { + out := make([]int64, 0, len(userIDs)) + seen := make(map[int64]struct{}, len(userIDs)) + for _, id := range userIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func (s *Service) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) { + store, ok := s.restrictions.(accountFreezeNotificationStore) + if !ok { + return nil, nil + } + return store.ClaimAccountFreezeNotifications(ctx, now, limit, lease) +} + +func (s *Service) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error { + store, ok := s.restrictions.(accountFreezeNotificationStore) + if !ok { + return nil + } + return store.CompleteAccountFreezeNotification(ctx, id, version, now) +} + func validateAccountFreeze(freeze domain.AccountFreeze) error { if !freeze.Frozen { if !freeze.Since.IsZero() || !freeze.Until.IsZero() || freeze.AppealURL != "" { @@ -476,6 +566,10 @@ func (s *Service) SetAccountFrozen(ctx context.Context, req SetAccountFrozenRequ return CommandResult{}, err } details["updated_at"] = updated.UpdatedAt.UTC().Format(time.RFC3339) + details["version"] = updated.Version + if err := s.notifyAccountFreezeChanged(ctx, updated); err != nil { + details["notify_error"] = err.Error() + } return CommandResult{Message: "account freeze updated", Details: details}, nil }) } @@ -1321,6 +1415,13 @@ func (s *Service) notifyUserChanged(ctx context.Context, u domain.User) error { return s.userNotifier.NotifyUserChanged(ctx, u) } +func (s *Service) notifyAccountFreezeChanged(ctx context.Context, freeze domain.AccountFreeze) error { + if s == nil || s.freezeNotifier == nil { + return nil + } + return s.freezeNotifier.NotifyAccountFreezeChanged(ctx, freeze) +} + func (s *Service) notifyStarsBalanceChanged(ctx context.Context, balance domain.StarsBalance) error { if s == nil || s.starsNotifier == nil { return nil diff --git a/internal/admin/service_test.go b/internal/admin/service_test.go index f48c4182..46da9654 100644 --- a/internal/admin/service_test.go +++ b/internal/admin/service_test.go @@ -21,10 +21,12 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { ctx := context.Background() repo := newMemoryCommandRepo() restrictions := &fakeRestrictionStore{} + notifier := &fakeAccountFreezeNotifier{} svc := NewService(Dependencies{ - Commands: repo, - Restrictions: restrictions, - Now: fixedNow, + Commands: repo, + Restrictions: restrictions, + FreezeNotifier: notifier, + Now: fixedNow, }) dry, err := svc.SetAccountFrozen(ctx, SetAccountFrozenRequest{ @@ -55,6 +57,9 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { if exec.Status != string(domain.AdminCommandCompleted) || restrictions.setCalls != 1 { t.Fatalf("execute result=%+v setCalls=%d", exec, restrictions.setCalls) } + if len(notifier.items) != 1 || notifier.items[0].UserID != 1001 || !notifier.items[0].Frozen || notifier.items[0].Version != 1 { + t.Fatalf("freeze notifications = %+v, want one versioned frozen state", notifier.items) + } if err := svc.CanSendMessages(ctx, 1001); !errors.Is(err, domain.ErrUserFrozen) { t.Fatalf("CanSendMessages err=%v, want ErrUserFrozen", err) } @@ -70,6 +75,32 @@ func TestSetAccountFrozenDryRunExecuteAndIdempotency(t *testing.T) { if !again.AlreadyExecuted || restrictions.setCalls != 1 { t.Fatalf("duplicate result=%+v setCalls=%d, want idempotent replay", again, restrictions.setCalls) } + if len(notifier.items) != 1 { + t.Fatalf("idempotent replay emitted duplicate notification: %+v", notifier.items) + } +} + +func TestAccountFreezesBatchesAndReturnsOnlyActiveFacts(t *testing.T) { + now := fixedNow() + store := &fakeBatchRestrictionStore{fakeRestrictionStore: fakeRestrictionStore{items: map[int64]domain.AccountFreeze{ + 1001: { + UserID: 1001, Frozen: true, Version: 2, Since: now, + Until: now.Add(time.Hour), AppealURL: "https://appeals.example.test/1001", + }, + 1002: {UserID: 1002, Frozen: false, Version: 4}, + }}} + svc := NewService(Dependencies{Restrictions: store, Now: fixedNow}) + + got, err := svc.AccountFreezes(context.Background(), []int64{1001, 1001, 0, 1002}) + if err != nil { + t.Fatalf("AccountFreezes: %v", err) + } + if len(store.requests) != 1 || !reflect.DeepEqual(store.requests[0], []int64{1001, 1002}) { + t.Fatalf("batch requests = %v, want one deduplicated request", store.requests) + } + if len(got) != 1 || !got[1001].Frozen || got[1001].Version != 2 { + t.Fatalf("AccountFreezes = %+v, want active user 1001 only", got) + } } func TestSetAccountFrozenRejectsIncompleteStateAndUnfreezeClearsOverlay(t *testing.T) { @@ -492,11 +523,37 @@ func (f *fakeRestrictionStore) SetAccountFreeze(_ context.Context, r domain.Acco f.items = map[int64]domain.AccountFreeze{} } f.setCalls++ + r.Version = f.items[r.UserID].Version + 1 r.UpdatedAt = fixedNow() f.items[r.UserID] = r return r, nil } +type fakeBatchRestrictionStore struct { + fakeRestrictionStore + requests [][]int64 +} + +func (f *fakeBatchRestrictionStore) GetAccountFreezes(_ context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) { + f.requests = append(f.requests, append([]int64(nil), userIDs...)) + out := make(map[int64]domain.AccountFreeze) + for _, id := range userIDs { + if freeze, ok := f.items[id]; ok && freeze.Frozen { + out[id] = freeze + } + } + return out, nil +} + +type fakeAccountFreezeNotifier struct { + items []domain.AccountFreeze +} + +func (f *fakeAccountFreezeNotifier) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error { + f.items = append(f.items, freeze) + return nil +} + type fakeMessagesService struct { byID []domain.Message deleteCalls int diff --git a/internal/app/contacts/read_model_cache.go b/internal/app/contacts/read_model_cache.go index 205d1027..d22adb80 100644 --- a/internal/app/contacts/read_model_cache.go +++ b/internal/app/contacts/read_model_cache.go @@ -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 } diff --git a/internal/app/contacts/service.go b/internal/app/contacts/service.go index 2fe8b5de..e73a8139 100644 --- a/internal/app/contacts/service.go +++ b/internal/app/contacts/service.go @@ -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), ) } diff --git a/internal/app/dialogs/read_model_cache.go b/internal/app/dialogs/read_model_cache.go index 299aab67..9178c6b2 100644 --- a/internal/app/dialogs/read_model_cache.go +++ b/internal/app/dialogs/read_model_cache.go @@ -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 } diff --git a/internal/app/dialogs/service.go b/internal/app/dialogs/service.go index 56dbb858..aae8c85a 100644 --- a/internal/app/dialogs/service.go +++ b/internal/app/dialogs/service.go @@ -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), ) } diff --git a/internal/app/messages/service.go b/internal/app/messages/service.go index 8099b7b5..82ee42fb 100644 --- a/internal/app/messages/service.go +++ b/internal/app/messages/service.go @@ -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 } diff --git a/internal/app/userprojection/contact_cache.go b/internal/app/userprojection/contact_cache.go index 64946a2e..412d66d4 100644 --- a/internal/app/userprojection/contact_cache.go +++ b/internal/app/userprojection/contact_cache.go @@ -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 } diff --git a/internal/app/userprojection/projection.go b/internal/app/userprojection/projection.go index ded9a3b5..5c654aca 100644 --- a/internal/app/userprojection/projection.go +++ b/internal/app/userprojection/projection.go @@ -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 diff --git a/internal/app/userprojection/projection_test.go b/internal/app/userprojection/projection_test.go index 7f17dfc2..6be85d50 100644 --- a/internal/app/userprojection/projection_test.go +++ b/internal/app/userprojection/projection_test.go @@ -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) } diff --git a/internal/app/users/service.go b/internal/app/users/service.go index 993727f2..eadf49d3 100644 --- a/internal/app/users/service.go +++ b/internal/app/users/service.go @@ -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 } diff --git a/internal/domain/admin.go b/internal/domain/admin.go index 6bf6d651..70aa900a 100644 --- a/internal/domain/admin.go +++ b/internal/domain/admin.go @@ -32,6 +32,7 @@ type AdminCommand struct { type AccountFreeze struct { UserID int64 Frozen bool + Version int64 Since time.Time Until time.Time AppealURL string @@ -40,3 +41,15 @@ type AccountFreeze struct { CommandID string UpdatedAt time.Time } + +// AccountFreezeNotification is a durable, coalesced online refresh for one +// viewer. UpdateUser itself has no pts; offline clients always recover from the +// authoritative viewer-scoped user projection instead of replaying this row. +type AccountFreezeNotification struct { + ID int64 + TargetUserID int64 + FrozenUserID int64 + Version int64 + Frozen bool + Attempts int +} diff --git a/internal/domain/user.go b/internal/domain/user.go index 22413065..fa2e1a0b 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -104,6 +104,10 @@ type User struct { Contact bool Mutual bool CloseFriend bool + // RestrictionReasons are transient, viewer-scoped unavailability reasons. + // They are produced after loading the viewer-independent base user and must + // never be persisted in users or the base-user cache. + RestrictionReasons []UserRestrictionReason // ContactNote/ContactNoteEntities are transient viewer-scoped contact // projection fields. They must never be persisted into users or a // viewer-independent base-user cache. @@ -153,6 +157,23 @@ type User struct { AccountDeleteAt time.Time } +// UserRestrictionReason is the protocol-neutral form of Telegram's +// restrictionReason. Platform "all" applies to TDesktop and official mobile +// clients; Text is intentionally server supplied and directly user-visible. +type UserRestrictionReason struct { + Platform string + Reason string + Text string +} + +func AccountFrozenRestrictionReasons() []UserRestrictionReason { + return []UserRestrictionReason{{ + Platform: "all", + Reason: "frozen", + Text: "This account is frozen.", + }} +} + // PremiumActiveAt 报告用户在 now(Unix 秒)时刻是否为有效会员。 // bot 永不为会员(官方语义;授予路径同样排除 bot,这里是双保险)。 func (u User) PremiumActiveAt(now int64) bool { diff --git a/internal/rpc/account_freeze_worker.go b/internal/rpc/account_freeze_worker.go new file mode 100644 index 00000000..80ea01cf --- /dev/null +++ b/internal/rpc/account_freeze_worker.go @@ -0,0 +1,107 @@ +package rpc + +import ( + "context" + "time" + + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap" + + "telesrv/internal/domain" +) + +type accountFreezeNotificationService interface { + ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) + CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error +} + +// RunAccountFreezeNotifications drains the crash-safe, coalesced non-pts +// updateUser queue. One attempt is enough for online delivery; offline clients +// recover the current state from viewer-scoped user hydration. +func (r *Router) RunAccountFreezeNotifications(ctx context.Context, interval time.Duration, batch int) { + if interval <= 0 { + interval = time.Minute + } + if batch <= 0 { + batch = 500 + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + r.drainAccountFreezeNotifications(ctx, batch) + select { + case <-ctx.Done(): + return + case <-ticker.C: + case <-r.accountFreezeWake: + } + } +} + +func (r *Router) drainAccountFreezeNotifications(ctx context.Context, batch int) { + svc, ok := r.deps.AccountFreeze.(accountFreezeNotificationService) + if !ok || r.deps.Users == nil { + return + } + for { + now := r.clock.Now().UTC() + claimCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + notifications, err := svc.ClaimAccountFreezeNotifications(claimCtx, now, batch, 2*time.Minute) + cancel() + if err != nil { + r.log.Warn("claim account freeze notifications failed", zap.Error(err)) + return + } + for _, notification := range notifications { + r.dispatchAccountFreezeNotification(ctx, svc, notification) + } + if len(notifications) < batch { + return + } + } +} + +func (r *Router) dispatchAccountFreezeNotification(ctx context.Context, svc accountFreezeNotificationService, notification domain.AccountFreezeNotification) { + peer := domain.Peer{Type: domain.PeerTypeUser, ID: notification.FrozenUserID} + if contacts, ok := r.deps.Contacts.(interface{ InvalidateViewers(...int64) }); ok { + contacts.InvalidateViewers(notification.TargetUserID) + } + if dialogs, ok := r.deps.Dialogs.(interface { + InvalidateDialog(int64, domain.Peer) + }); ok { + dialogs.InvalidateDialog(notification.TargetUserID, peer) + } + r.invalidateRPCProjectionForPeer(notification.TargetUserID, peer) + + loadCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + user, found, err := r.deps.Users.ByID(loadCtx, notification.TargetUserID, notification.FrozenUserID) + cancel() + if err != nil { + r.log.Warn("load frozen user projection for notification failed", + zap.Int64("target_user_id", notification.TargetUserID), + zap.Int64("frozen_user_id", notification.FrozenUserID), + zap.Int64("version", notification.Version), + zap.Error(err)) + return + } + if !found { + user = domain.User{ID: notification.FrozenUserID, Deleted: true} + } + pushCtx, pushCancel := context.WithTimeout(ctx, 10*time.Second) + r.pushUserUpdates(pushCtx, notification.TargetUserID, &tg.Updates{ + Updates: []tg.UpdateClass{&tg.UpdateUser{UserID: notification.FrozenUserID}}, + Users: r.tgUsersForViewer(notification.TargetUserID, []domain.User{user}), + Date: int(r.clock.Now().Unix()), + }) + pushCancel() + + completeCtx, completeCancel := context.WithTimeout(ctx, 10*time.Second) + err = svc.CompleteAccountFreezeNotification(completeCtx, notification.ID, notification.Version, r.clock.Now().UTC()) + completeCancel() + if err != nil { + r.log.Warn("complete account freeze notification failed", + zap.Int64("notification_id", notification.ID), + zap.Int64("version", notification.Version), + zap.Error(err)) + } +} diff --git a/internal/rpc/account_freeze_worker_test.go b/internal/rpc/account_freeze_worker_test.go new file mode 100644 index 00000000..c9f39f42 --- /dev/null +++ b/internal/rpc/account_freeze_worker_test.go @@ -0,0 +1,136 @@ +package rpc + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/iamxvbaba/td/clock" + "github.com/iamxvbaba/td/tg" + "go.uber.org/zap/zaptest" + + "telesrv/internal/domain" +) + +func TestAccountFreezeNotificationPushesCurrentViewerProjection(t *testing.T) { + const ( + viewerID = int64(1001) + frozenID = int64(1002) + ) + sessions := &captureSessions{} + freezeSvc := &freezeWorkerService{} + users := &freezeWorkerUsers{user: domain.User{ + ID: frozenID, + FirstName: "Frozen", + RestrictionReasons: domain.AccountFrozenRestrictionReasons(), + }} + r := New(Config{}, Deps{ + AccountFreeze: freezeSvc, + Users: users, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + + r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, domain.AccountFreezeNotification{ + ID: 7, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 4, Frozen: true, + }) + + if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{7, 4} { + t.Fatalf("completed = %v, want [[7 4]]", freezeSvc.completed) + } + if got := sessions.pushedUserIDs(); len(got) != 1 || got[0] != viewerID { + t.Fatalf("pushed user IDs = %v, want [%d]", got, viewerID) + } + updates, ok := sessions.lastUserPush().(*tg.Updates) + if !ok || len(updates.Updates) != 1 || len(updates.Users) != 1 { + t.Fatalf("push = %#v, want updateUser plus projected user", sessions.lastUserPush()) + } + if update, ok := updates.Updates[0].(*tg.UpdateUser); !ok || update.UserID != frozenID { + t.Fatalf("update = %#v, want updateUser(%d)", updates.Updates[0], frozenID) + } + projected, ok := updates.Users[0].(*tg.User) + if !ok || !projected.Restricted { + t.Fatalf("projected user = %#v, want restricted user", updates.Users[0]) + } + reasons, ok := projected.GetRestrictionReason() + if !ok || len(reasons) != 1 || reasons[0].Reason != "frozen" { + t.Fatalf("projected restriction = %+v ok=%v", reasons, ok) + } +} + +func TestAccountFreezeNotificationLoadsCurrentStateAndRetriesLoadFailure(t *testing.T) { + const ( + viewerID = int64(2001) + frozenID = int64(2002) + ) + sessions := &captureSessions{} + freezeSvc := &freezeWorkerService{} + users := &freezeWorkerUsers{err: errors.New("projection unavailable")} + r := New(Config{}, Deps{ + AccountFreeze: freezeSvc, + Users: users, + Sessions: sessions, + }, zaptest.NewLogger(t), clock.System) + notification := domain.AccountFreezeNotification{ + ID: 8, TargetUserID: viewerID, FrozenUserID: frozenID, Version: 5, Frozen: true, + } + + r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification) + if len(freezeSvc.completed) != 0 || len(sessions.pushedUserIDs()) != 0 { + t.Fatalf("failed load completed=%v pushes=%v, want retry without push", freezeSvc.completed, sessions.pushedUserIDs()) + } + + // The queued payload may say frozen, but delivery must hydrate the latest + // viewer projection so a newer unfreeze can never be overwritten by stale work. + users.err = nil + users.user = domain.User{ID: frozenID, FirstName: "Active"} + r.dispatchAccountFreezeNotification(context.Background(), freezeSvc, notification) + updates, ok := sessions.lastUserPush().(*tg.Updates) + if !ok || len(updates.Users) != 1 { + t.Fatalf("push = %#v", sessions.lastUserPush()) + } + projected, ok := updates.Users[0].(*tg.User) + if !ok || projected.Restricted { + t.Fatalf("latest projected user = %#v, want unrestricted", updates.Users[0]) + } + if len(freezeSvc.completed) != 1 || freezeSvc.completed[0] != [2]int64{8, 5} { + t.Fatalf("completed = %v, want [[8 5]]", freezeSvc.completed) + } +} + +type freezeWorkerService struct { + completed [][2]int64 +} + +func (*freezeWorkerService) AccountFreeze(context.Context, int64) (domain.AccountFreeze, bool, error) { + return domain.AccountFreeze{}, false, nil +} + +func (*freezeWorkerService) ClaimAccountFreezeNotifications(context.Context, time.Time, int, time.Duration) ([]domain.AccountFreezeNotification, error) { + return nil, nil +} + +func (s *freezeWorkerService) CompleteAccountFreezeNotification(_ context.Context, id, version int64, _ time.Time) error { + s.completed = append(s.completed, [2]int64{id, version}) + return nil +} + +type freezeWorkerUsers struct { + user domain.User + err error +} + +func (s *freezeWorkerUsers) Self(context.Context, int64) (domain.User, error) { + return s.user, s.err +} + +func (s *freezeWorkerUsers) ByID(context.Context, int64, int64) (domain.User, bool, error) { + return s.user, s.err == nil, s.err +} + +func (s *freezeWorkerUsers) ByIDs(context.Context, int64, []int64) ([]domain.User, error) { + if s.err != nil { + return nil, s.err + } + return []domain.User{s.user}, nil +} diff --git a/internal/rpc/admin_hooks.go b/internal/rpc/admin_hooks.go index 51caf393..a85e365c 100644 --- a/internal/rpc/admin_hooks.go +++ b/internal/rpc/admin_hooks.go @@ -46,3 +46,20 @@ func (r *Router) NotifyStarsBalanceChanged(ctx context.Context, balance domain.S }) return nil } + +// NotifyAccountFreezeChanged invalidates target-scoped projections immediately +// and wakes the durable audience nudge worker. Cross-instance cache invalidation +// is also carried by the committed user_visibility read-model notification. +func (r *Router) NotifyAccountFreezeChanged(_ context.Context, freeze domain.AccountFreeze) error { + if r == nil || freeze.UserID == 0 { + return nil + } + r.invalidateRPCProjectionForUser(freeze.UserID) + if r.accountFreezeWake != nil { + select { + case r.accountFreezeWake <- struct{}{}: + default: + } + } + return nil +} diff --git a/internal/rpc/convert_users.go b/internal/rpc/convert_users.go index 1093453b..66f05805 100644 --- a/internal/rpc/convert_users.go +++ b/internal/rpc/convert_users.go @@ -30,6 +30,7 @@ func tgSelfUser(u domain.User) *tg.User { applyTgUserBotFields(out, u) applyTgUserPremiumFields(out, u) applyTgUserColorFields(out, u) + applyTgUserRestrictionFields(out, u) if u.LinkedCommunityID != 0 { out.SetLinkedCommunityID(u.LinkedCommunityID) } @@ -60,6 +61,7 @@ func tgUser(u domain.User) *tg.User { applyTgUserBotFields(out, u) applyTgUserPremiumFields(out, u) applyTgUserColorFields(out, u) + applyTgUserRestrictionFields(out, u) if u.LinkedCommunityID != 0 { out.SetLinkedCommunityID(u.LinkedCommunityID) } @@ -69,6 +71,28 @@ func tgUser(u domain.User) *tg.User { return out } +func applyTgUserRestrictionFields(out *tg.User, u domain.User) { + if out == nil || len(u.RestrictionReasons) == 0 { + return + } + reasons := make([]tg.RestrictionReason, 0, len(u.RestrictionReasons)) + for _, reason := range u.RestrictionReasons { + if reason.Platform == "" || reason.Reason == "" || reason.Text == "" { + continue + } + reasons = append(reasons, tg.RestrictionReason{ + Platform: reason.Platform, + Reason: reason.Reason, + Text: reason.Text, + }) + } + if len(reasons) == 0 { + return + } + out.Restricted = true + out.SetRestrictionReason(reasons) +} + // applyTgUserPremiumFields 由到期时间即时派生 premium flag(bit28,独立位)与 // emoji status。判断用真实时钟:premium 的权威来源是 premium_expires_at 本身, // 到期即停发,正确性不依赖后台 sweeper(它只负责清理与 updateUser 通知); diff --git a/internal/rpc/convert_users_restriction_test.go b/internal/rpc/convert_users_restriction_test.go new file mode 100644 index 00000000..d7280f4f --- /dev/null +++ b/internal/rpc/convert_users_restriction_test.go @@ -0,0 +1,62 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/bin" + "github.com/iamxvbaba/td/tg" + "github.com/iamxvbaba/td/tlprofile" + + "telesrv/internal/domain" +) + +func TestTgUserEncodesFrozenRestriction(t *testing.T) { + user := tgUser(domain.User{ + ID: 1001, + FirstName: "Frozen", + RestrictionReasons: domain.AccountFrozenRestrictionReasons(), + }) + if !user.Restricted { + t.Fatal("tg user restricted=false, want true") + } + reasons, ok := user.GetRestrictionReason() + if !ok || len(reasons) != 1 { + t.Fatalf("restriction_reason = %+v ok=%v, want one reason", reasons, ok) + } + if got := reasons[0]; got.Platform != "all" || got.Reason != "frozen" || got.Text != "This account is frozen." { + t.Fatalf("restriction_reason = %+v", got) + } + + for profile := tlprofile.Profile225; profile <= tlprofile.Profile228; profile++ { + wire := &bin.Buffer{} + if err := tlprofile.EncodeObject(profile, user, wire); err != nil { + t.Fatalf("encode layer %d frozen user: %v", profile, err) + } + decoded, err := tlprofile.DecodeObject(profile, &bin.Buffer{Buf: wire.Copy()}, tlprofile.Limits{}) + if err != nil { + t.Fatalf("decode layer %d frozen user: %v", profile, err) + } + exact, ok := decoded.(*tg.User) + if !ok || !exact.Restricted { + t.Fatalf("layer %d user = %#v, want restricted", profile, decoded) + } + exactReasons, ok := exact.GetRestrictionReason() + if !ok || len(exactReasons) != 1 || exactReasons[0].Reason != "frozen" { + t.Fatalf("layer %d restriction = %+v ok=%v", profile, exactReasons, ok) + } + } +} + +func TestTgUserSkipsIncompleteRestriction(t *testing.T) { + user := tgUser(domain.User{ + ID: 1001, + FirstName: "Active", + RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "frozen"}}, + }) + if user.Restricted { + t.Fatal("incomplete restriction was encoded") + } + if reasons, ok := user.GetRestrictionReason(); ok || len(reasons) != 0 { + t.Fatalf("restriction_reason = %+v ok=%v, want omitted", reasons, ok) + } +} diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 97a560b8..1bf9b11e 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -164,6 +164,7 @@ type Router struct { stickerCatalog *stickerCatalogCache transientPrivateBigReactions transientPrivateBigReactionCache accountSettings *accountSettingsCache + accountFreezeWake chan struct{} // webPageResolveSem 是链接预览异步解析的并发信号量(有界):发送后把 pending 占位 // 解析为卡片并就地替换。满则丢弃任务(消息留 pending)。nil=未启用(测试可直接调 // resolvePendingWebPage 同步验证)。 @@ -237,7 +238,7 @@ func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { if instanceID == "" { instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) } - r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), instanceID: instanceID} + r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), accountFreezeWake: make(chan struct{}, 1), instanceID: instanceID} r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer) r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer) r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) diff --git a/internal/store/postgres/admin.go b/internal/store/postgres/admin.go index 497754d6..f4641685 100644 --- a/internal/store/postgres/admin.go +++ b/internal/store/postgres/admin.go @@ -168,7 +168,7 @@ func scanAdminCommand(row pgx.Row) (domain.AdminCommand, error) { func (s *AdminStore) GetAccountFreeze(ctx context.Context, userID int64) (domain.AccountFreeze, bool, error) { row := s.db.QueryRow(ctx, ` -SELECT user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at +SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at FROM account_restrictions WHERE user_id = $1`, userID) r, err := scanAccountFreeze(row) @@ -181,13 +181,80 @@ WHERE user_id = $1`, userID) return r, true, nil } +func (s *AdminStore) GetAccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error) { + out := make(map[int64]domain.AccountFreeze) + if s == nil || s.db == nil || len(userIDs) == 0 { + return out, nil + } + rows, err := s.db.Query(ctx, ` +SELECT user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at +FROM account_restrictions +WHERE user_id = ANY($1::bigint[]) AND frozen = true`, userIDs) + if err != nil { + return nil, fmt.Errorf("get account freezes: %w", err) + } + defer rows.Close() + for rows.Next() { + freeze, err := scanAccountFreeze(rows) + if err != nil { + return nil, fmt.Errorf("scan account freeze: %w", err) + } + out[freeze.UserID] = freeze + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate account freezes: %w", err) + } + return out, nil +} + func (s *AdminStore) SetAccountFreeze(ctx context.Context, freeze domain.AccountFreeze) (domain.AccountFreeze, error) { + beginner, ok := s.db.(txBeginner) + if !ok { + return setAccountFreezeRow(ctx, s.db, freeze) + } + tx, err := beginner.Begin(ctx) + if err != nil { + return domain.AccountFreeze{}, fmt.Errorf("begin set account freeze: %w", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + out, err := setAccountFreezeRow(ctx, tx, freeze) + if err != nil { + return domain.AccountFreeze{}, err + } + if err := enqueueAccountFreezeNotifications(ctx, tx, out); err != nil { + return domain.AccountFreeze{}, err + } + // User visibility participates in the same cache/version invalidation spine + // as profile and dialog changes. These functions emit cross-instance NOTIFY + // events only after the surrounding transaction commits. + if _, err := tx.Exec(ctx, `SELECT telesrv_bump_contact_accounts_for_user($1)`, out.UserID); err != nil { + return domain.AccountFreeze{}, fmt.Errorf("bump frozen user contact projections: %w", err) + } + if _, err := tx.Exec(ctx, `SELECT telesrv_bump_private_dialog_light_for_user($1)`, out.UserID); err != nil { + return domain.AccountFreeze{}, fmt.Errorf("bump frozen user dialog projections: %w", err) + } + if _, err := tx.Exec(ctx, `SELECT telesrv_bump_read_model_version('user_visibility', 0, 'user', $1)`, out.UserID); err != nil { + return domain.AccountFreeze{}, fmt.Errorf("bump frozen user visibility: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return domain.AccountFreeze{}, fmt.Errorf("commit set account freeze: %w", err) + } + committed = true + return out, nil +} + +func setAccountFreezeRow(ctx context.Context, db sqlcgen.DBTX, freeze domain.AccountFreeze) (domain.AccountFreeze, error) { var since, until any if freeze.Frozen { since = freeze.Since until = freeze.Until } - row := s.db.QueryRow(ctx, ` + row := db.QueryRow(ctx, ` INSERT INTO account_restrictions ( user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at ) @@ -200,8 +267,9 @@ ON CONFLICT (user_id) DO UPDATE SET reason = EXCLUDED.reason, actor = EXCLUDED.actor, command_id = EXCLUDED.command_id, + version = account_restrictions.version + 1, updated_at = now() -RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`, +RETURNING user_id, frozen, version, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at`, freeze.UserID, freeze.Frozen, since, until, freeze.AppealURL, freeze.Reason, freeze.Actor, freeze.CommandID, ) out, err := scanAccountFreeze(row) @@ -211,12 +279,16 @@ RETURNING user_id, frozen, frozen_since, frozen_until, appeal_url, reason, actor return out, nil } -func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) { +type accountFreezeScanner interface { + Scan(dest ...any) error +} + +func scanAccountFreeze(row accountFreezeScanner) (domain.AccountFreeze, error) { var r domain.AccountFreeze var since, until pgtype.Timestamptz var updated time.Time if err := row.Scan( - &r.UserID, &r.Frozen, &since, &until, &r.AppealURL, + &r.UserID, &r.Frozen, &r.Version, &since, &until, &r.AppealURL, &r.Reason, &r.Actor, &r.CommandID, &updated, ); err != nil { return domain.AccountFreeze{}, err @@ -230,3 +302,84 @@ func scanAccountFreeze(row pgx.Row) (domain.AccountFreeze, error) { r.UpdatedAt = updated return r, nil } + +func enqueueAccountFreezeNotifications(ctx context.Context, tx pgx.Tx, freeze domain.AccountFreeze) error { + const maxAccountFreezeNotificationAudience = 4096 + _, err := tx.Exec(ctx, ` +INSERT INTO account_freeze_notifications (target_user_id, frozen_user_id, version, frozen) +SELECT audience.user_id, $1, $2, $3 +FROM ( + SELECT user_id + FROM ( + SELECT contact_user_id AS user_id, 0 AS priority, 0 AS activity + FROM contacts WHERE user_id = $1 + UNION ALL + SELECT user_id, 0, 0 FROM contacts WHERE contact_user_id = $1 + UNION ALL + SELECT peer_id, 1, top_message_date + FROM dialogs WHERE user_id = $1 AND peer_type = 'user' + UNION ALL + SELECT user_id, 1, top_message_date + FROM dialogs WHERE peer_type = 'user' AND peer_id = $1 + ) candidates + GROUP BY user_id + ORDER BY min(priority), max(activity) DESC, user_id + LIMIT $4 +) audience +JOIN users u ON u.id = audience.user_id +WHERE audience.user_id <> $1 AND u.deleted_at IS NULL +ON CONFLICT (target_user_id, frozen_user_id) DO UPDATE SET + version = EXCLUDED.version, + frozen = EXCLUDED.frozen, + status = 'pending', + attempts = 0, + next_attempt_at = now(), + lease_until = NULL, + last_error = '', + updated_at = now()`, freeze.UserID, freeze.Version, freeze.Frozen, maxAccountFreezeNotificationAudience) + if err != nil { + return fmt.Errorf("enqueue account freeze notifications: %w", err) + } + return nil +} + +func (s *AdminStore) ClaimAccountFreezeNotifications(ctx context.Context, now time.Time, limit int, lease time.Duration) ([]domain.AccountFreezeNotification, error) { + if s == nil || s.db == nil || limit <= 0 || lease <= 0 { + return nil, nil + } + rows, err := s.db.Query(ctx, ` +WITH claim AS ( + SELECT id FROM account_freeze_notifications + WHERE (status = 'pending' AND next_attempt_at <= $1) + OR (status = 'dispatching' AND lease_until <= $1) + ORDER BY next_attempt_at, id FOR UPDATE SKIP LOCKED LIMIT $2 +) +UPDATE account_freeze_notifications n +SET status = 'dispatching', attempts = attempts + 1, lease_until = $3, updated_at = $1 +FROM claim WHERE n.id = claim.id +RETURNING n.id, n.target_user_id, n.frozen_user_id, n.version, n.frozen, n.attempts`, now, limit, now.Add(lease)) + if err != nil { + return nil, fmt.Errorf("claim account freeze notifications: %w", err) + } + defer rows.Close() + out := make([]domain.AccountFreezeNotification, 0) + for rows.Next() { + var n domain.AccountFreezeNotification + if err := rows.Scan(&n.ID, &n.TargetUserID, &n.FrozenUserID, &n.Version, &n.Frozen, &n.Attempts); err != nil { + return nil, fmt.Errorf("scan account freeze notification: %w", err) + } + out = append(out, n) + } + return out, rows.Err() +} + +func (s *AdminStore) CompleteAccountFreezeNotification(ctx context.Context, id, version int64, now time.Time) error { + _, err := s.db.Exec(ctx, ` +UPDATE account_freeze_notifications +SET status = 'delivered', lease_until = NULL, last_error = '', updated_at = $3 +WHERE id = $1 AND version = $2`, id, version, now) + if err != nil { + return fmt.Errorf("complete account freeze notification: %w", err) + } + return nil +} diff --git a/internal/store/postgres/admin_freeze_integration_test.go b/internal/store/postgres/admin_freeze_integration_test.go index b49fac19..299cfbec 100644 --- a/internal/store/postgres/admin_freeze_integration_test.go +++ b/internal/store/postgres/admin_freeze_integration_test.go @@ -33,11 +33,12 @@ func TestAccountFreezeMigrationAndStoreRoundTrip(t *testing.T) { const ( frozenUserID = int64(1999999881) activeUserID = int64(1999999882) + observerID = int64(1999999883) ) for _, user := range []struct { id int64 phone string - }{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}} { + }{{frozenUserID, "1999999881"}, {activeUserID, "1999999882"}, {observerID, "1999999883"}} { if _, err := tx.Exec(ctx, ` INSERT INTO users (id, access_hash, phone, first_name) VALUES ($1, $1, $2, 'Freeze migration test')`, user.id, user.phone); err != nil { @@ -60,10 +61,32 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l t.Fatalf("GetAccountFreeze migrated = %+v found=%v err=%v", migrated, found, err) } if !migrated.Frozen || !migrated.Since.Equal(legacyUpdatedAt) || - !migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" { + !migrated.Until.Equal(legacyUpdatedAt.Add(7*24*time.Hour)) || migrated.AppealURL != "https://t.me/SpamBot" || migrated.Version != 1 { t.Fatalf("migrated freeze = %+v", migrated) } + if _, err := tx.Exec(ctx, ` +INSERT INTO contacts (user_id, contact_user_id, contact_first_name) +VALUES ($1, $2, 'Visible frozen peer')`, observerID, activeUserID); err != nil { + t.Fatalf("insert observer contact: %v", err) + } + if _, err := tx.Exec(ctx, ` +INSERT INTO dialogs (user_id, peer_type, peer_id, top_message_id, top_message_date) +VALUES ($1, 'user', $2, 1, 100)`, observerID, activeUserID); err != nil { + t.Fatalf("insert observer dialog: %v", err) + } + var contactVersionBefore, dialogVersionBefore int64 + if err := tx.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionBefore); err != nil { + t.Fatalf("read initial contact projection version: %v", err) + } + if err := tx.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionBefore); err != nil { + t.Fatalf("read initial dialog projection version: %v", err) + } + since := time.Date(2026, 7, 15, 2, 0, 0, 0, time.UTC) want := domain.AccountFreeze{ UserID: activeUserID, @@ -75,21 +98,80 @@ VALUES ($1, true, 'legacy freeze', 'ops', 'legacy-freeze', $2)`, frozenUserID, l Actor: "ops", CommandID: "freeze-round-trip", } - if _, err := store.SetAccountFreeze(ctx, want); err != nil { + updated, err := store.SetAccountFreeze(ctx, want) + if err != nil { t.Fatalf("SetAccountFreeze active: %v", err) } + if updated.Version != 1 { + t.Fatalf("first freeze version = %d, want 1", updated.Version) + } got, found, err := store.GetAccountFreeze(ctx, activeUserID) if err != nil || !found || !got.Frozen || !got.Since.Equal(want.Since) || - !got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL { + !got.Until.Equal(want.Until) || got.AppealURL != want.AppealURL || got.Version != 1 { t.Fatalf("active round trip = %+v found=%v err=%v", got, found, err) } - if _, err := store.SetAccountFreeze(ctx, domain.AccountFreeze{ + var contactVersionAfter, dialogVersionAfter, visibilityVersion int64 + if err := tx.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, observerID).Scan(&contactVersionAfter); err != nil { + t.Fatalf("read frozen contact projection version: %v", err) + } + if err := tx.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2`, observerID, activeUserID).Scan(&dialogVersionAfter); err != nil { + t.Fatalf("read frozen dialog projection version: %v", err) + } + if contactVersionAfter <= contactVersionBefore || dialogVersionAfter <= dialogVersionBefore { + t.Fatalf("projection versions contact %d->%d dialog %d->%d, want increments", + contactVersionBefore, contactVersionAfter, dialogVersionBefore, dialogVersionAfter) + } + if err := tx.QueryRow(ctx, ` +SELECT version FROM read_model_versions +WHERE model = 'user_visibility' AND owner_user_id = 0 AND peer_type = 'user' AND peer_id = $1`, activeUserID).Scan(&visibilityVersion); err != nil || visibilityVersion != 1 { + t.Fatalf("user visibility version = %d err=%v, want 1", visibilityVersion, err) + } + + claimAt := time.Now().UTC().Add(time.Minute) + claimed, err := store.ClaimAccountFreezeNotifications(ctx, claimAt, 10, time.Minute) + if err != nil || len(claimed) != 1 { + t.Fatalf("claim frozen notification = %+v err=%v, want one", claimed, err) + } + oldNotification := claimed[0] + if oldNotification.TargetUserID != observerID || oldNotification.FrozenUserID != activeUserID || !oldNotification.Frozen || oldNotification.Version != 1 { + t.Fatalf("frozen notification = %+v", oldNotification) + } + + updated, err = store.SetAccountFreeze(ctx, domain.AccountFreeze{ UserID: activeUserID, Reason: "appeal accepted", Actor: "ops", CommandID: "unfreeze-round-trip", - }); err != nil { + }) + if err != nil { t.Fatalf("SetAccountFreeze inactive: %v", err) } + if updated.Version != 2 { + t.Fatalf("unfreeze version = %d, want 2", updated.Version) + } + // A worker that claimed v1 before the unfreeze cannot acknowledge the + // coalesced v2 row and suppress its online refresh. + if err := store.CompleteAccountFreezeNotification(ctx, oldNotification.ID, oldNotification.Version, claimAt); err != nil { + t.Fatalf("complete stale notification: %v", err) + } + claimed, err = store.ClaimAccountFreezeNotifications(ctx, claimAt.Add(time.Minute), 10, time.Minute) + if err != nil || len(claimed) != 1 { + t.Fatalf("claim unfreeze notification = %+v err=%v, want one", claimed, err) + } + newNotification := claimed[0] + if newNotification.ID != oldNotification.ID || newNotification.Version != 2 || newNotification.Frozen { + t.Fatalf("coalesced unfreeze notification = %+v, previous=%+v", newNotification, oldNotification) + } + if err := store.CompleteAccountFreezeNotification(ctx, newNotification.ID, newNotification.Version, claimAt.Add(2*time.Minute)); err != nil { + t.Fatalf("complete unfreeze notification: %v", err) + } + var notificationStatus string + if err := tx.QueryRow(ctx, `SELECT status FROM account_freeze_notifications WHERE id = $1`, newNotification.ID).Scan(¬ificationStatus); err != nil || notificationStatus != "delivered" { + t.Fatalf("notification status = %q err=%v, want delivered", notificationStatus, err) + } got, found, err = store.GetAccountFreeze(ctx, activeUserID) - if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" { + if err != nil || !found || got.Frozen || !got.Since.IsZero() || !got.Until.IsZero() || got.AppealURL != "" || got.Version != 2 { t.Fatalf("inactive round trip = %+v found=%v err=%v", got, found, err) } diff --git a/internal/store/postgres/read_model_listener.go b/internal/store/postgres/read_model_listener.go index 0bf27c2a..0a4b0cbf 100644 --- a/internal/store/postgres/read_model_listener.go +++ b/internal/store/postgres/read_model_listener.go @@ -348,6 +348,15 @@ func (l *ReadModelChangeListener) handlePayload(payload string) { l.caches.BotProfiles.InvalidateBotProfileReadModel(evt.PeerID) } } + case "user_visibility": + if evt.PeerType == "user" && evt.PeerID != 0 { + if l.caches.RPCProjections != nil { + l.caches.RPCProjections.InvalidateRPCProjectionReadModelForUser(evt.PeerID) + } + if l.caches.Stories != nil { + l.caches.Stories.InvalidateStoryReadModelPeer(domain.Peer{Type: domain.PeerTypeUser, ID: evt.PeerID}) + } + } case "bot_full": // bot 资料(name/about/description/commands/menu_button)变更经 bot_info_version // bump 触发(迁移 0013)。channelFullBotInfoCache 按 (viewer,channel) 键、无法按 botID diff --git a/internal/store/postgres/story_peer_read_model_test.go b/internal/store/postgres/story_peer_read_model_test.go index de7af269..4e1adb18 100644 --- a/internal/store/postgres/story_peer_read_model_test.go +++ b/internal/store/postgres/story_peer_read_model_test.go @@ -17,6 +17,19 @@ type fakeStoryReadModelCache struct { flushes int } +type fakeRPCProjectionReadModelCache struct { + users []int64 +} + +func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForViewer(int64) {} +func (f *fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForUser(id int64) { + f.users = append(f.users, id) +} +func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForPeer(int64, domain.Peer) { +} +func (*fakeRPCProjectionReadModelCache) InvalidateRPCProjectionReadModelForChannel(int64) {} +func (*fakeRPCProjectionReadModelCache) FlushRPCProjectionReadModel() {} + func (f *fakeStoryReadModelCache) InvalidateStoryReadModelViewers(ids ...int64) { f.mu.Lock() defer f.mu.Unlock() @@ -78,6 +91,29 @@ func TestReadModelChangeListenerRoutesStoryPeer(t *testing.T) { } } +func TestReadModelChangeListenerRoutesUserVisibility(t *testing.T) { + stories := &fakeStoryReadModelCache{} + rpcProjections := &fakeRPCProjectionReadModelCache{} + listener := NewReadModelChangeListener("", ReadModelCacheSet{ + Stories: stories, + RPCProjections: rpcProjections, + }, nil) + + listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":777,"version":2}`) + if len(rpcProjections.users) != 1 || rpcProjections.users[0] != 777 { + t.Fatalf("RPC projection invalidations = %v, want [777]", rpcProjections.users) + } + if peers := stories.peersSnapshot(); len(peers) != 1 || peers[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 777}) { + t.Fatalf("story projection invalidations = %+v, want user 777", peers) + } + + listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"channel","peer_id":888,"version":3}`) + listener.handlePayload(`{"model":"user_visibility","owner_user_id":0,"peer_type":"user","peer_id":0,"version":4}`) + if len(rpcProjections.users) != 1 || len(stories.peersSnapshot()) != 1 { + t.Fatalf("invalid visibility events were not ignored: users=%v peers=%+v", rpcProjections.users, stories.peersSnapshot()) + } +} + // TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite 验证 0135 触发器:写 stories / // story_hidden_peers → story_peer bump → 统一 read-model NOTIFY → 按 owner peer 失效故事投影。 func TestStoryPeerReadModelNotifyInvalidatesOnStoryWrite(t *testing.T) {