fix: sync account freeze peer visibility
This commit is contained in:
parent
d9875b5caa
commit
eba402946a
26 changed files with 1034 additions and 19 deletions
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
37
deploy/migrations/0131_account_freeze_visibility.up.sql
Normal file
37
deploy/migrations/0131_account_freeze_visibility.up.sql
Normal file
|
|
@ -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';
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -132,5 +132,8 @@ func cloneUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Service struct {
|
|||
users store.UserStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy phonePrivacyService
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
cache *contactListReadModelCache
|
||||
|
|
@ -49,6 +50,10 @@ func WithPrivacyEvaluator(p phonePrivacyService) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable hash-token fast paths for NotModified RPCs.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -84,6 +89,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -502,6 +502,9 @@ func cloneDialogUser(in domain.User) domain.User {
|
|||
if in.PhotoStripped != nil {
|
||||
in.PhotoStripped = append([]byte(nil), in.PhotoStripped...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
premium PremiumChecker
|
||||
projector *userprojection.Projector
|
||||
versions store.ReadModelVersionStore
|
||||
|
|
@ -54,6 +55,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithReadModelVersions enables durable version-token backed peer dialog caching.
|
||||
func WithReadModelVersions(v store.ReadModelVersionStore) Option {
|
||||
return func(s *Service) { s.versions = v }
|
||||
|
|
@ -93,6 +98,7 @@ func (s *Service) rebuildProjector() {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos userprojection.ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
versions store.ReadModelVersionStore
|
||||
projector *userprojection.Projector
|
||||
botResponder BotResponder
|
||||
|
|
@ -57,6 +58,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
// WithBotResponder 启用服务端内置 bot(BotFather)对私聊消息的自动应答。
|
||||
func WithBotResponder(r BotResponder) Option {
|
||||
return func(s *Service) { s.botResponder = r }
|
||||
|
|
@ -85,6 +90,7 @@ func NewService(messages store.MessageStore, dialogs store.DialogStore, opts ...
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -454,6 +454,9 @@ func cloneCachedUser(in domain.User) domain.User {
|
|||
if in.ContactNoteEntities != nil {
|
||||
in.ContactNoteEntities = append([]domain.MessageEntity(nil), in.ContactNoteEntities...)
|
||||
}
|
||||
if in.RestrictionReasons != nil {
|
||||
in.RestrictionReasons = append([]domain.UserRestrictionReason(nil), in.RestrictionReasons...)
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ type PrivacyEvaluator interface {
|
|||
CanSee(ctx context.Context, ownerUserID, viewerUserID int64, key domain.PrivacyKey) (bool, error)
|
||||
}
|
||||
|
||||
// AccountFreezeProvider returns durable account freeze facts for a bounded
|
||||
// batch. The projector only exposes them to viewers other than the frozen user.
|
||||
type AccountFreezeProvider interface {
|
||||
AccountFreezes(ctx context.Context, userIDs []int64) (map[int64]domain.AccountFreeze, error)
|
||||
}
|
||||
|
||||
// BatchPrivacyEvaluator 批量评估多 owner 对单 viewer 的可见性,消除 projectBatch / fan-out
|
||||
// 投影里 per-user 3×CanSee 的 N+1。可选:实现了它的 evaluator(privacy.Service)会被
|
||||
// projectBatch 优先用批量预取,否则回退逐 CanSee。结果必须与逐 CanSee 字节等价。
|
||||
|
|
@ -52,6 +58,7 @@ type Projector struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy PrivacyEvaluator
|
||||
freezes AccountFreezeProvider
|
||||
}
|
||||
|
||||
// Option configures a Projector.
|
||||
|
|
@ -72,6 +79,11 @@ func WithPrivacyEvaluator(privacy PrivacyEvaluator) Option {
|
|||
return func(p *Projector) { p.privacy = privacy }
|
||||
}
|
||||
|
||||
// WithAccountFreezeProvider enables viewer-scoped frozen-account visibility.
|
||||
func WithAccountFreezeProvider(provider AccountFreezeProvider) Option {
|
||||
return func(p *Projector) { p.freezes = provider }
|
||||
}
|
||||
|
||||
// New creates a user projector.
|
||||
func New(opts ...Option) *Projector {
|
||||
p := &Projector{}
|
||||
|
|
@ -87,7 +99,7 @@ func (p *Projector) ForViewer(ctx context.Context, viewerUserID int64, users []d
|
|||
if p == nil {
|
||||
return users, nil
|
||||
}
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, viewerUserID, users)
|
||||
return projectBatch(ctx, p.contacts, p.photos, p.privacy, p.freezes, viewerUserID, users)
|
||||
}
|
||||
|
||||
// One applies ForViewer to a single user.
|
||||
|
|
@ -136,6 +148,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
fallbackRefs map[int64]domain.ProfilePhotoRef
|
||||
contactsByViewer map[int64]map[int64]domain.Contact
|
||||
matrix map[int64]map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
g, gctx := errgroup.WithContext(ctx)
|
||||
// 1) 共享头像:profile/fallback 一次批量,跨全部 viewer 复用;personal photo v1 跳过(见 doc)。
|
||||
|
|
@ -159,6 +172,13 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return err
|
||||
})
|
||||
}
|
||||
if p.freezes != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
freezes, err = p.freezes.AccountFreezes(gctx, ids)
|
||||
return err
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -194,6 +214,7 @@ func (p *Projector) ForViewers(ctx context.Context, viewerUserIDs []int64, users
|
|||
return nil, perr
|
||||
}
|
||||
}
|
||||
pj = applyAccountFreezeProjection(pj, viewer, freezes[u.ID])
|
||||
cache[u.ID] = pj
|
||||
projected[i] = pj
|
||||
}
|
||||
|
|
@ -262,6 +283,7 @@ func cloneUsers(users []domain.User) []domain.User {
|
|||
copy(out, users)
|
||||
for i := range out {
|
||||
out[i].ContactNoteEntities = append([]domain.MessageEntity(nil), out[i].ContactNoteEntities...)
|
||||
out[i].RestrictionReasons = append([]domain.UserRestrictionReason(nil), out[i].RestrictionReasons...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -357,7 +379,7 @@ func One(ctx context.Context, contacts store.ContactStore, viewerUserID int64, u
|
|||
return projected[0], nil
|
||||
}
|
||||
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
func projectBatch(ctx context.Context, contacts store.ContactStore, photos ProfilePhotoProvider, privacy PrivacyEvaluator, freezesProvider AccountFreezeProvider, viewerUserID int64, users []domain.User) ([]domain.User, error) {
|
||||
if len(users) == 0 {
|
||||
return users, nil
|
||||
}
|
||||
|
|
@ -371,6 +393,7 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
personalRefs = map[int64]domain.ProfilePhotoRef{}
|
||||
contactsByID map[int64]domain.Contact
|
||||
visibility map[int64]map[domain.PrivacyKey]bool
|
||||
freezes map[int64]domain.AccountFreeze
|
||||
)
|
||||
// 这些预取查询互不依赖(头像 profile/fallback、联系人 GetMany/PersonalPhotos、privacy 可见性),
|
||||
// 并发执行把 ~6 次串行 round-trip 收敛成一波;每个 goroutine 只写自己那一个变量,组装循环在
|
||||
|
|
@ -433,6 +456,16 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
visibility = v
|
||||
return nil
|
||||
})
|
||||
if freezesProvider != nil && len(ids) > 0 {
|
||||
g.Go(func() error {
|
||||
m, err := freezesProvider.AccountFreezes(gctx, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
freezes = m
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -462,12 +495,23 @@ func projectBatch(ctx context.Context, contacts store.ContactStore, photos Profi
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
projected = applyAccountFreezeProjection(projected, viewerUserID, freezes[u.ID])
|
||||
cache[u.ID] = projected
|
||||
out[i] = projected
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func applyAccountFreezeProjection(user domain.User, viewerUserID int64, freeze domain.AccountFreeze) domain.User {
|
||||
// Base users and self users must never retain a viewer-scoped restriction.
|
||||
user.RestrictionReasons = nil
|
||||
if user.Deleted || viewerUserID == 0 || user.ID == 0 || user.ID == viewerUserID || !freeze.Frozen {
|
||||
return user
|
||||
}
|
||||
user.RestrictionReasons = domain.AccountFrozenRestrictionReasons()
|
||||
return user
|
||||
}
|
||||
|
||||
func prefetchPrivacyVisibility(ctx context.Context, privacy PrivacyEvaluator, viewerUserID int64, users []domain.User) (map[int64]map[domain.PrivacyKey]bool, error) {
|
||||
if privacy == nil || viewerUserID == 0 {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -119,6 +119,64 @@ func TestProjectorUsesFallbackWhenProfilePhotoHidden(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestProjectorAccountFreezeIsViewerScopedAndReversible(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const (
|
||||
frozenUserID = int64(4001)
|
||||
otherViewer = int64(4002)
|
||||
)
|
||||
freezes := &fakeAccountFreezes{items: map[int64]domain.AccountFreeze{
|
||||
frozenUserID: {UserID: frozenUserID, Frozen: true, Version: 3},
|
||||
}}
|
||||
projector := New(WithAccountFreezeProvider(freezes))
|
||||
base := []domain.User{{
|
||||
ID: frozenUserID,
|
||||
FirstName: "Frozen",
|
||||
// Viewer-scoped fields must never be trusted from a reused base object.
|
||||
RestrictionReasons: []domain.UserRestrictionReason{{Platform: "all", Reason: "stale", Text: "stale"}},
|
||||
}}
|
||||
|
||||
otherView, err := projector.ForViewer(ctx, otherViewer, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(other): %v", err)
|
||||
}
|
||||
got := projectionUser(t, otherView, frozenUserID)
|
||||
if !reflect.DeepEqual(got.RestrictionReasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("other-view restriction = %+v, want frozen restriction", got.RestrictionReasons)
|
||||
}
|
||||
if base[0].RestrictionReasons[0].Reason != "stale" {
|
||||
t.Fatalf("projection mutated base user: %+v", base[0])
|
||||
}
|
||||
|
||||
selfView, err := projector.ForViewer(ctx, frozenUserID, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(self): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, selfView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
batch, err := projector.ForViewers(ctx, []int64{otherViewer, frozenUserID}, base)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewers: %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[otherViewer], frozenUserID).RestrictionReasons; !reflect.DeepEqual(reasons, domain.AccountFrozenRestrictionReasons()) {
|
||||
t.Fatalf("batch other-view restriction = %+v", reasons)
|
||||
}
|
||||
if reasons := projectionUser(t, batch[frozenUserID], frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("batch self-view restriction = %+v, want none", reasons)
|
||||
}
|
||||
|
||||
freezes.items = nil
|
||||
unfrozenView, err := projector.ForViewer(ctx, otherViewer, otherView)
|
||||
if err != nil {
|
||||
t.Fatalf("ForViewer(after unfreeze): %v", err)
|
||||
}
|
||||
if reasons := projectionUser(t, unfrozenView, frozenUserID).RestrictionReasons; len(reasons) != 0 {
|
||||
t.Fatalf("unfrozen projection retained restriction = %+v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
// TestForViewersEquivalentToForViewer 锁定 fan-out 模板化的核心安全网:ForViewers(viewers, users)
|
||||
// 的每个 viewer 切片必须与逐 viewer 的 ForViewer(viewer, users) 字节等价(隐私/改名/头像投影
|
||||
// 不能因 O(owner) 模板化而漂移泄漏)。**唯一允许的差异是 personal photo overlay**:v1 模板不做
|
||||
|
|
@ -243,6 +301,20 @@ type fakeProfilePhotos struct {
|
|||
fallback map[int64]domain.ProfilePhotoRef
|
||||
}
|
||||
|
||||
type fakeAccountFreezes struct {
|
||||
items map[int64]domain.AccountFreeze
|
||||
}
|
||||
|
||||
func (f *fakeAccountFreezes) AccountFreezes(_ context.Context, ids []int64) (map[int64]domain.AccountFreeze, error) {
|
||||
out := make(map[int64]domain.AccountFreeze)
|
||||
for _, id := range ids {
|
||||
if freeze, ok := f.items[id]; ok {
|
||||
out[id] = freeze
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p fakeProfilePhotos) CurrentProfilePhotos(_ context.Context, _ domain.PeerType, ids []int64) (map[int64]domain.ProfilePhotoRef, error) {
|
||||
return p.CurrentProfilePhotosKind(context.Background(), domain.PeerTypeUser, ids, domain.ProfilePhotoKindProfile)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ type Service struct {
|
|||
contacts store.ContactStore
|
||||
photos ProfilePhotoProvider
|
||||
privacy userprojection.PrivacyEvaluator
|
||||
freezes userprojection.AccountFreezeProvider
|
||||
projector *userprojection.Projector
|
||||
}
|
||||
|
||||
|
|
@ -55,6 +56,10 @@ func WithPrivacyEvaluator(p userprojection.PrivacyEvaluator) Option {
|
|||
return func(s *Service) { s.privacy = p }
|
||||
}
|
||||
|
||||
func WithAccountFreezeProvider(p userprojection.AccountFreezeProvider) Option {
|
||||
return func(s *Service) { s.freezes = p }
|
||||
}
|
||||
|
||||
const (
|
||||
minUsernameLen = 5
|
||||
maxUsernameLen = 32
|
||||
|
|
@ -77,6 +82,7 @@ func NewService(users store.UserStore, opts ...Option) *Service {
|
|||
userprojection.WithContactStore(s.contacts),
|
||||
userprojection.WithPhotoProvider(s.photos),
|
||||
userprojection.WithPrivacyEvaluator(s.privacy),
|
||||
userprojection.WithAccountFreezeProvider(s.freezes),
|
||||
)
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
107
internal/rpc/account_freeze_worker.go
Normal file
107
internal/rpc/account_freeze_worker.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
136
internal/rpc/account_freeze_worker_test.go
Normal file
136
internal/rpc/account_freeze_worker_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 通知);
|
||||
|
|
|
|||
62
internal/rpc/convert_users_restriction_test.go
Normal file
62
internal/rpc/convert_users_restriction_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue