fix: sync account freeze peer visibility

This commit is contained in:
A 2026-07-22 02:03:00 +08:00
parent d9875b5caa
commit eba402946a
26 changed files with 1034 additions and 19 deletions

View file

@ -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"))
}

View file

@ -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;

View 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';

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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),
)
}

View file

@ -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
}

View file

@ -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),
)
}

View file

@ -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 启用服务端内置 botBotFather对私聊消息的自动应答。
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
}

View file

@ -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
}

View file

@ -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。可选实现了它的 evaluatorprivacy.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

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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 报告用户在 nowUnix 秒)时刻是否为有效会员。
// bot 永不为会员(官方语义;授予路径同样排除 bot这里是双保险
func (u User) PremiumActiveAt(now int64) bool {

View 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))
}
}

View 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
}

View file

@ -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
}

View file

@ -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 flagbit28独立位
// emoji status。判断用真实时钟premium 的权威来源是 premium_expires_at 本身,
// 到期即停发,正确性不依赖后台 sweeper它只负责清理与 updateUser 通知);

View 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)
}
}

View file

@ -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)

View file

@ -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
}

View file

@ -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(&notificationStatus); 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)
}

View file

@ -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

View file

@ -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) {