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

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