fix: sync account freeze peer visibility
This commit is contained in:
parent
d9875b5caa
commit
eba402946a
26 changed files with 1034 additions and 19 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue