feat: complete remaining Go backend modules
This commit is contained in:
1 parent
cea2751dc5
commit
aef5a97165
145 files changed
+18376
-199
No files matched your search
@@ -141,6 +141,30 @@ type UpdateAccountInput struct {
|
||||
Password *string
|
||||
ClearLoginLock bool
|
||||
}
|
||||
|
||||
// AccountUpdate is the storage intent for an account PATCH. Implementations
|
||||
// must only mutate fields whose pointers/flags are present. In particular,
|
||||
// security state that is not part of this intent must remain database-owned so
|
||||
// a concurrent password change or login attempt cannot be overwritten by a
|
||||
// stale account snapshot.
|
||||
type AccountUpdate struct {
|
||||
Actor Actor
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
OrganizationID *string
|
||||
Status *Status
|
||||
PasswordHash *PasswordHash
|
||||
ClearLoginLock bool
|
||||
IncrementSessionVersion bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// AtomicAccountUpdater is an optional deep storage seam for implementations
|
||||
// that can apply AccountUpdate atomically. Store remains backward compatible
|
||||
// for process-local/test adapters; durable stores should implement this seam.
|
||||
type AtomicAccountUpdater interface {
|
||||
ApplyAccountUpdate(context.Context, string, AccountUpdate) (Account, error)
|
||||
}
|
||||
type UpdateOrganizationInput struct {
|
||||
Name *string
|
||||
Status *Status
|
||||
|
||||
@@ -109,6 +109,7 @@ func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, pat
|
||||
return Account{}, problem(ErrorForbidden, "组织管理员不能修改账号角色或归属。")
|
||||
}
|
||||
next := current
|
||||
update := AccountUpdate{Actor: actor}
|
||||
mutates := false
|
||||
if patch.DisplayName != nil {
|
||||
name := strings.TrimSpace(*patch.DisplayName)
|
||||
@@ -116,16 +117,20 @@ func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, pat
|
||||
return Account{}, problem(ErrorValidation, "显示名称不能为空。")
|
||||
}
|
||||
next.DisplayName = name
|
||||
update.DisplayName = &name
|
||||
}
|
||||
if patch.Role != nil {
|
||||
if !validRole(*patch.Role) {
|
||||
return Account{}, problem(ErrorValidation, "账号角色不正确。")
|
||||
}
|
||||
next.Role = *patch.Role
|
||||
update.Role = patch.Role
|
||||
mutates = true
|
||||
}
|
||||
if patch.OrganizationID != nil {
|
||||
next.OrganizationID = strings.TrimSpace(*patch.OrganizationID)
|
||||
organizationID := next.OrganizationID
|
||||
update.OrganizationID = &organizationID
|
||||
mutates = true
|
||||
}
|
||||
if patch.Status != nil {
|
||||
@@ -133,6 +138,7 @@ func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, pat
|
||||
return Account{}, problem(ErrorValidation, "账号状态不正确。")
|
||||
}
|
||||
next.Status = *patch.Status
|
||||
update.Status = patch.Status
|
||||
mutates = true
|
||||
}
|
||||
if err := s.validateMembership(ctx, next.Role, next.OrganizationID); err != nil {
|
||||
@@ -147,15 +153,23 @@ func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, pat
|
||||
return Account{}, infrastructure("hash password", err)
|
||||
}
|
||||
next.PasswordHash, next.PasswordSalt = hashed.Hash, hashed.Salt
|
||||
update.PasswordHash = &hashed
|
||||
mutates = true
|
||||
}
|
||||
if patch.ClearLoginLock {
|
||||
next.FailedLoginCount, next.LockedUntil = 0, nil
|
||||
update.ClearLoginLock = true
|
||||
}
|
||||
if mutates {
|
||||
next.SessionVersion++
|
||||
update.IncrementSessionVersion = true
|
||||
}
|
||||
next.UpdatedAt = s.now()
|
||||
update.UpdatedAt = next.UpdatedAt
|
||||
if atomic, ok := s.store.(AtomicAccountUpdater); ok {
|
||||
updated, err := atomic.ApplyAccountUpdate(ctx, id, update)
|
||||
return updated, infrastructure("update account", err)
|
||||
}
|
||||
updated, err := s.store.UpdateAccount(ctx, next)
|
||||
return updated, infrastructure("update account", err)
|
||||
}
|
||||
|
||||
@@ -217,3 +217,54 @@ func (s *fakeStore) DeleteOrganization(context.Context, string) error { return s
|
||||
func (s *fakeStore) CountOrganizationMembers(context.Context, string) (int, error) {
|
||||
return s.memberCount, s.err
|
||||
}
|
||||
|
||||
func TestUpdateAccountPrefersAtomicIntentOverStaleFullRowWrite(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC)
|
||||
base := &fakeStore{
|
||||
accounts: map[string]Account{"user-1": {
|
||||
ID: "user-1", DisplayName: "Before", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive,
|
||||
PasswordHash: "stale-hash", PasswordSalt: "stale-salt", SessionVersion: 4,
|
||||
}},
|
||||
organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}},
|
||||
}
|
||||
store := &atomicAccountStore{fakeStore: base, result: Account{
|
||||
ID: "user-1", DisplayName: "After", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive,
|
||||
PasswordHash: "concurrent-password-hash", PasswordSalt: "concurrent-password-salt", SessionVersion: 8,
|
||||
}}
|
||||
service := NewService(store, WithClock(func() time.Time { return now }))
|
||||
name := "After"
|
||||
|
||||
got, err := service.UpdateAccount(context.Background(), Actor{ID: "super", Role: RoleSuperAdmin}, "user-1", UpdateAccountInput{DisplayName: &name})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.fullRowWrites != 0 {
|
||||
t.Fatalf("full-row writes=%d, want zero", store.fullRowWrites)
|
||||
}
|
||||
if store.id != "user-1" || store.update.DisplayName == nil || *store.update.DisplayName != "After" {
|
||||
t.Fatalf("atomic update=(id=%q, update=%#v)", store.id, store.update)
|
||||
}
|
||||
if store.update.PasswordHash != nil || store.update.IncrementSessionVersion {
|
||||
t.Fatalf("display-only update must not overwrite password or increment session: %#v", store.update)
|
||||
}
|
||||
if got.PasswordHash != "concurrent-password-hash" || got.SessionVersion != 8 {
|
||||
t.Fatalf("result=%#v, want database-current security state", got)
|
||||
}
|
||||
}
|
||||
|
||||
type atomicAccountStore struct {
|
||||
*fakeStore
|
||||
id string
|
||||
update AccountUpdate
|
||||
result Account
|
||||
fullRowWrites int
|
||||
}
|
||||
|
||||
func (s *atomicAccountStore) UpdateAccount(ctx context.Context, account Account) (Account, error) {
|
||||
s.fullRowWrites++
|
||||
return s.fakeStore.UpdateAccount(ctx, account)
|
||||
}
|
||||
func (s *atomicAccountStore) ApplyAccountUpdate(_ context.Context, id string, update AccountUpdate) (Account, error) {
|
||||
s.id, s.update = id, update
|
||||
return s.result, nil
|
||||
}
|
||||
Reference in new issue
Block a user