feat: complete remaining Go backend modules

This commit is contained in:
zn-admin committed 2026-08-14 00:02:43 +08:00
1 parent cea2751dc5
commit aef5a97165
145 files changed
+18376 -199

No files matched your search

+24
View File
@@ -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
}