From cea2751dc5fe82b4b28702ae39262c40b404d285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AE=97=E7=90=A6?= <442782435@qq.com> Date: Thu, 13 Aug 2026 16:32:07 +0800 Subject: [PATCH] feat: add administration domain core --- backend/internal/administration/model.go | 173 ++++++++++++ .../internal/administration/organizations.go | 82 ++++++ backend/internal/administration/password.go | 29 ++ backend/internal/administration/service.go | 218 +++++++++++++++ .../internal/administration/service_test.go | 219 +++++++++++++++ backend/internal/postgres/administration.go | 253 ++++++++++++++++++ .../internal/postgres/administration_test.go | 96 +++++++ .../admin/accounts-organizations-v1.json | 23 ++ ...in-accounts-organizations-contract.test.ts | 42 +++ 9 files changed, 1135 insertions(+) create mode 100644 backend/internal/administration/model.go create mode 100644 backend/internal/administration/organizations.go create mode 100644 backend/internal/administration/password.go create mode 100644 backend/internal/administration/service.go create mode 100644 backend/internal/administration/service_test.go create mode 100644 backend/internal/postgres/administration.go create mode 100644 backend/internal/postgres/administration_test.go create mode 100644 contracts/admin/accounts-organizations-v1.json create mode 100644 tests/admin-accounts-organizations-contract.test.ts diff --git a/backend/internal/administration/model.go b/backend/internal/administration/model.go new file mode 100644 index 0000000..b3834eb --- /dev/null +++ b/backend/internal/administration/model.go @@ -0,0 +1,173 @@ +package administration + +import ( + "context" + "errors" + "time" +) + +type Role string + +const ( + RoleSuperAdmin Role = "super_admin" + RoleOrganizationAdmin Role = "organization_admin" + RoleUser Role = "user" +) + +type Status string + +const ( + StatusActive Status = "active" + StatusDisabled Status = "disabled" +) + +type Actor struct { + ID string + Role Role + OrganizationID string +} +type Account struct { + ID, Phone, DisplayName string + Role Role + OrganizationID string + Status Status + PasswordHash, PasswordSalt string + FailedLoginCount int + LockedUntil *time.Time + SessionVersion int + LastLoginAt *time.Time + LegacySubject string + CreatedAt, UpdatedAt time.Time +} +type Organization struct { + ID, Name string + Status Status + ArchiveOwnerID string + CreatedAt, UpdatedAt time.Time +} +type AccountProjection struct { + ID, Phone, DisplayName string + Role Role + OrganizationID string + Status Status + CreatedAt time.Time + LastLoginAt, LockedUntil *time.Time +} +type OrganizationProjection struct { + ID, Name string + Status Status +} +type AccountFilters struct { + OrganizationID string + Role Role + IncludeDisabled bool +} +type PasswordHash struct{ Hash, Salt string } +type Store interface { + ListAccounts(context.Context, AccountFilters) ([]Account, error) + GetAccount(context.Context, string) (Account, bool, error) + CreateAccount(context.Context, Account) (Account, error) + UpdateAccount(context.Context, Account) (Account, error) + DeleteAccount(context.Context, string, string) error + ListOrganizations(context.Context, bool) ([]Organization, error) + GetOrganization(context.Context, string) (Organization, bool, error) + CreateOrganization(context.Context, Organization) (Organization, error) + UpdateOrganization(context.Context, Organization) (Organization, error) + DeleteOrganization(context.Context, string) error + CountOrganizationMembers(context.Context, string) (int, error) +} +type ErrorKind string + +const ( + ErrorValidation ErrorKind = "validation" + ErrorForbidden ErrorKind = "forbidden" + ErrorNotFound ErrorKind = "not_found" + ErrorConflict ErrorKind = "conflict" + ErrorInfrastructure ErrorKind = "infrastructure" +) + +type Error struct { + Kind ErrorKind + Message string + Err error +} + +func (e *Error) Error() string { return e.Message } +func (e *Error) Unwrap() error { return e.Err } +func IsKind(err error, kind ErrorKind) bool { + var target *Error + return errors.As(err, &target) && target.Kind == kind +} +func StatusCode(err error) int { + var target *Error + if !errors.As(err, &target) { + return 500 + } + switch target.Kind { + case ErrorValidation: + return 400 + case ErrorForbidden: + return 403 + case ErrorNotFound: + return 404 + case ErrorConflict: + return 409 + default: + return 500 + } +} +func problem(kind ErrorKind, message string) error { return &Error{Kind: kind, Message: message} } +func infrastructure(operation string, err error) error { + if err == nil { + return nil + } + var typed *Error + if errors.As(err, &typed) { + return err + } + return &Error{Kind: ErrorInfrastructure, Message: operation + ": " + err.Error(), Err: err} +} + +type CreateAccountInput struct { + Phone, DisplayName, Password string + Role Role + OrganizationID, LegacySubject string +} +type UpdateAccountInput struct { + DisplayName *string + Role *Role + OrganizationID *string + Status *Status + Password *string + ClearLoginLock bool +} +type UpdateOrganizationInput struct { + Name *string + Status *Status +} + +func AuthorizeAccountTarget(actor Actor, target Account) error { + if actor.Role == RoleSuperAdmin { + return nil + } + if actor.Role == RoleOrganizationAdmin && target.Role == RoleUser && actor.OrganizationID != "" && actor.OrganizationID == target.OrganizationID { + return nil + } + return problem(ErrorForbidden, "组织管理员只能管理本组织普通用户。") +} +func requireSuper(actor Actor) error { + if actor.Role != RoleSuperAdmin { + return problem(ErrorForbidden, "需要超级管理员权限。") + } + return nil +} +func validRole(role Role) bool { + return role == RoleSuperAdmin || role == RoleOrganizationAdmin || role == RoleUser +} +func validStatus(status Status) bool { return status == StatusActive || status == StatusDisabled } +func ProjectAccount(a Account) AccountProjection { + return AccountProjection{ID: a.ID, Phone: a.Phone, DisplayName: a.DisplayName, Role: a.Role, OrganizationID: a.OrganizationID, Status: a.Status, CreatedAt: a.CreatedAt, LastLoginAt: a.LastLoginAt, LockedUntil: a.LockedUntil} +} +func ProjectOrganization(o Organization) OrganizationProjection { + return OrganizationProjection{ID: o.ID, Name: o.Name, Status: o.Status} +} diff --git a/backend/internal/administration/organizations.go b/backend/internal/administration/organizations.go new file mode 100644 index 0000000..c45e0a0 --- /dev/null +++ b/backend/internal/administration/organizations.go @@ -0,0 +1,82 @@ +package administration + +import ( + "context" + "strings" +) + +func (s *Service) ListOrganizations(ctx context.Context, actor Actor) ([]OrganizationProjection, error) { + include := actor.Role == RoleSuperAdmin + if actor.Role != RoleSuperAdmin && actor.Role != RoleOrganizationAdmin { + return nil, problem(ErrorForbidden, "需要管理员权限。") + } + organizations, err := s.store.ListOrganizations(ctx, include) + if err != nil { + return nil, infrastructure("list organizations", err) + } + out := make([]OrganizationProjection, 0, len(organizations)) + for _, org := range organizations { + if actor.Role == RoleSuperAdmin || org.ID == actor.OrganizationID { + out = append(out, ProjectOrganization(org)) + } + } + return out, nil +} +func (s *Service) CreateOrganization(ctx context.Context, actor Actor, name string) (Organization, error) { + if err := requireSuper(actor); err != nil { + return Organization{}, err + } + name = strings.TrimSpace(name) + if name == "" { + return Organization{}, problem(ErrorValidation, "组织名称不能为空。") + } + now := s.now() + id := s.id("org") + org := Organization{ID: id, Name: name, Status: StatusActive, ArchiveOwnerID: "archive:" + id, CreatedAt: now, UpdatedAt: now} + created, err := s.store.CreateOrganization(ctx, org) + return created, infrastructure("create organization", err) +} +func (s *Service) UpdateOrganization(ctx context.Context, actor Actor, id string, patch UpdateOrganizationInput) (Organization, error) { + if err := requireSuper(actor); err != nil { + return Organization{}, err + } + current, found, err := s.store.GetOrganization(ctx, id) + if err != nil { + return Organization{}, infrastructure("get organization", err) + } + if !found { + return Organization{}, problem(ErrorNotFound, "组织不存在。") + } + if patch.Name != nil { + name := strings.TrimSpace(*patch.Name) + if name == "" { + return Organization{}, problem(ErrorValidation, "组织名称不能为空。") + } + current.Name = name + } + if patch.Status != nil { + if !validStatus(*patch.Status) { + return Organization{}, problem(ErrorValidation, "组织状态不正确。") + } + current.Status = *patch.Status + } + current.UpdatedAt = s.now() + updated, err := s.store.UpdateOrganization(ctx, current) + return updated, infrastructure("update organization", err) +} +func (s *Service) DeleteOrganization(ctx context.Context, actor Actor, id string) error { + if err := requireSuper(actor); err != nil { + return err + } + count, err := s.store.CountOrganizationMembers(ctx, id) + if err != nil { + return infrastructure("count organization members", err) + } + if count > 0 { + return problem(ErrorConflict, "组织仍有账号,不能删除。") + } + if err := s.store.DeleteOrganization(ctx, id); err != nil { + return infrastructure("delete organization", err) + } + return nil +} diff --git a/backend/internal/administration/password.go b/backend/internal/administration/password.go new file mode 100644 index 0000000..ff74d64 --- /dev/null +++ b/backend/internal/administration/password.go @@ -0,0 +1,29 @@ +package administration + +import ( + "crypto/rand" + "encoding/hex" + "golang.org/x/crypto/scrypt" + "regexp" + "strings" +) + +var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) + +func NormalizePhone(value string) string { + r := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "(", "", ")", "", "-", "") + return r.Replace(strings.TrimSpace(value)) +} +func ValidPhone(value string) bool { return phonePattern.MatchString(NormalizePhone(value)) } +func HashPassword(password string) (PasswordHash, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return PasswordHash{}, err + } + encoded := hex.EncodeToString(salt) + derived, err := scrypt.Key([]byte(password), []byte(encoded), 16384, 8, 1, 64) + if err != nil { + return PasswordHash{}, err + } + return PasswordHash{Hash: hex.EncodeToString(derived), Salt: encoded}, nil +} diff --git a/backend/internal/administration/service.go b/backend/internal/administration/service.go new file mode 100644 index 0000000..296c9dd --- /dev/null +++ b/backend/internal/administration/service.go @@ -0,0 +1,218 @@ +package administration + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" +) + +type Service struct { + store Store + id func(string) string + now func() time.Time + hash func(string) (PasswordHash, error) +} +type Option func(*Service) + +func WithIDGenerator(fn func(string) string) Option { return func(s *Service) { s.id = fn } } +func WithClock(fn func() time.Time) Option { return func(s *Service) { s.now = fn } } +func WithPasswordHasher(fn func(string) (PasswordHash, error)) Option { + return func(s *Service) { s.hash = fn } +} +func NewService(store Store, options ...Option) *Service { + s := &Service{store: store, id: randomID, now: time.Now, hash: HashPassword} + for _, option := range options { + option(s) + } + return s +} +func randomID(prefix string) string { + raw := make([]byte, 12) + if _, err := rand.Read(raw); err != nil { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) + } + return prefix + "-" + hex.EncodeToString(raw) +} + +func (s *Service) ListAccounts(ctx context.Context, actor Actor, filters AccountFilters) ([]AccountProjection, error) { + if actor.Role == RoleOrganizationAdmin { + if actor.OrganizationID == "" { + return nil, problem(ErrorForbidden, "当前账号没有组织归属。") + } + filters.OrganizationID, filters.Role = actor.OrganizationID, RoleUser + } else if actor.Role != RoleSuperAdmin { + return nil, problem(ErrorForbidden, "需要管理员权限。") + } + accounts, err := s.store.ListAccounts(ctx, filters) + if err != nil { + return nil, infrastructure("list accounts", err) + } + out := make([]AccountProjection, len(accounts)) + for i, a := range accounts { + out[i] = ProjectAccount(a) + } + return out, nil +} +func (s *Service) CreateAccount(ctx context.Context, actor Actor, input CreateAccountInput) (Account, error) { + input.Phone = NormalizePhone(input.Phone) + input.DisplayName = strings.TrimSpace(input.DisplayName) + if !ValidPhone(input.Phone) { + return Account{}, problem(ErrorValidation, "手机号格式不正确。") + } + if input.DisplayName == "" { + return Account{}, problem(ErrorValidation, "显示名称不能为空。") + } + if len(input.Password) < 8 { + return Account{}, problem(ErrorValidation, "初始密码至少需要 8 位。") + } + if !validRole(input.Role) { + return Account{}, problem(ErrorValidation, "账号角色不正确。") + } + if actor.Role == RoleOrganizationAdmin { + if input.Role != RoleUser { + return Account{}, problem(ErrorForbidden, "组织管理员只能创建普通用户。") + } + if actor.OrganizationID == "" { + return Account{}, problem(ErrorForbidden, "当前账号没有组织归属。") + } + input.OrganizationID = actor.OrganizationID + } else if actor.Role != RoleSuperAdmin { + return Account{}, problem(ErrorForbidden, "需要管理员权限。") + } + if err := s.validateMembership(ctx, input.Role, input.OrganizationID); err != nil { + return Account{}, err + } + hashed, err := s.hash(input.Password) + if err != nil { + return Account{}, infrastructure("hash password", err) + } + now := s.now() + a := Account{ID: s.id("user"), Phone: input.Phone, DisplayName: input.DisplayName, Role: input.Role, OrganizationID: input.OrganizationID, Status: StatusActive, PasswordHash: hashed.Hash, PasswordSalt: hashed.Salt, SessionVersion: 1, LegacySubject: strings.TrimSpace(input.LegacySubject), CreatedAt: now, UpdatedAt: now} + created, err := s.store.CreateAccount(ctx, a) + return created, infrastructure("create account", err) +} +func (s *Service) UpdateAccount(ctx context.Context, actor Actor, id string, patch UpdateAccountInput) (Account, error) { + current, found, err := s.store.GetAccount(ctx, id) + if err != nil { + return Account{}, infrastructure("get account", err) + } + if !found { + return Account{}, problem(ErrorNotFound, "账号不存在。") + } + if err := AuthorizeAccountTarget(actor, current); err != nil { + return Account{}, err + } + if actor.Role != RoleSuperAdmin && ((patch.Role != nil && *patch.Role != current.Role) || patch.OrganizationID != nil) { + return Account{}, problem(ErrorForbidden, "组织管理员不能修改账号角色或归属。") + } + next := current + mutates := false + if patch.DisplayName != nil { + name := strings.TrimSpace(*patch.DisplayName) + if name == "" { + return Account{}, problem(ErrorValidation, "显示名称不能为空。") + } + next.DisplayName = name + } + if patch.Role != nil { + if !validRole(*patch.Role) { + return Account{}, problem(ErrorValidation, "账号角色不正确。") + } + next.Role = *patch.Role + mutates = true + } + if patch.OrganizationID != nil { + next.OrganizationID = strings.TrimSpace(*patch.OrganizationID) + mutates = true + } + if patch.Status != nil { + if !validStatus(*patch.Status) { + return Account{}, problem(ErrorValidation, "账号状态不正确。") + } + next.Status = *patch.Status + mutates = true + } + if err := s.validateMembership(ctx, next.Role, next.OrganizationID); err != nil { + return Account{}, err + } + if patch.Password != nil { + if len(*patch.Password) < 8 { + return Account{}, problem(ErrorValidation, "新密码至少需要 8 位。") + } + hashed, err := s.hash(*patch.Password) + if err != nil { + return Account{}, infrastructure("hash password", err) + } + next.PasswordHash, next.PasswordSalt = hashed.Hash, hashed.Salt + mutates = true + } + if patch.ClearLoginLock { + next.FailedLoginCount, next.LockedUntil = 0, nil + } + if mutates { + next.SessionVersion++ + } + next.UpdatedAt = s.now() + updated, err := s.store.UpdateAccount(ctx, next) + return updated, infrastructure("update account", err) +} +func (s *Service) SetAccountStatus(ctx context.Context, actor Actor, id string, status Status) (Account, error) { + if actor.ID == id && status == StatusDisabled { + return Account{}, problem(ErrorValidation, "不能停用当前登录账号。") + } + return s.UpdateAccount(ctx, actor, id, UpdateAccountInput{Status: &status, ClearLoginLock: status == StatusActive}) +} +func (s *Service) ResetPassword(ctx context.Context, actor Actor, id, password string) (Account, error) { + return s.UpdateAccount(ctx, actor, id, UpdateAccountInput{Password: &password, ClearLoginLock: true}) +} +func (s *Service) DeleteAccount(ctx context.Context, actor Actor, id string) (string, error) { + current, found, err := s.store.GetAccount(ctx, id) + if err != nil { + return "", infrastructure("get account", err) + } + if !found { + return "", problem(ErrorNotFound, "账号不存在。") + } + if err := AuthorizeAccountTarget(actor, current); err != nil { + return "", err + } + if actor.ID == id { + return "", problem(ErrorValidation, "不能删除当前登录账号。") + } + if current.Role == RoleSuperAdmin { + return "", problem(ErrorValidation, "不能直接删除超级管理员账号。") + } + archive := "archive:global" + if current.OrganizationID != "" { + org, found, err := s.store.GetOrganization(ctx, current.OrganizationID) + if err != nil { + return "", infrastructure("get organization", err) + } + if found { + archive = org.ArchiveOwnerID + } + } + if err := s.store.DeleteAccount(ctx, id, archive); err != nil { + return "", infrastructure("delete account", err) + } + return archive, nil +} +func (s *Service) validateMembership(ctx context.Context, role Role, organizationID string) error { + if role == RoleSuperAdmin && organizationID == "" { + return nil + } + if organizationID == "" { + return problem(ErrorValidation, "普通账号必须归属有效组织。") + } + org, found, err := s.store.GetOrganization(ctx, organizationID) + if err != nil { + return infrastructure("get organization", err) + } + if !found || org.Status != StatusActive { + return problem(ErrorValidation, "账号归属的组织不存在或已停用。") + } + return nil +} diff --git a/backend/internal/administration/service_test.go b/backend/internal/administration/service_test.go new file mode 100644 index 0000000..b99c2cf --- /dev/null +++ b/backend/internal/administration/service_test.go @@ -0,0 +1,219 @@ +package administration + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + "time" +) + +type contractFixture struct { + PhoneCases []struct { + Input, Normalized string + Valid bool + } `json:"phoneCases"` + AuthorizationCases []struct { + Name, ActorRole, ActorOrganizationID, TargetRole, TargetOrganizationID string + Allowed bool + } `json:"authorizationCases"` + SessionVersionCases []struct { + Name string + RoleChanged, OrganizationChanged, StatusChanged, PasswordChanged, ClearLoginLock, Increment bool + } `json:"sessionVersionCases"` +} + +func TestPhoneContract(t *testing.T) { + fixture := loadContract(t) + for _, tc := range fixture.PhoneCases { + if got := NormalizePhone(tc.Input); got != tc.Normalized { + t.Errorf("NormalizePhone(%q)=%q want %q", tc.Input, got, tc.Normalized) + } + if got := ValidPhone(tc.Input); got != tc.Valid { + t.Errorf("ValidPhone(%q)=%v want %v", tc.Input, got, tc.Valid) + } + } +} + +func TestAccountTargetAuthorizationContract(t *testing.T) { + fixture := loadContract(t) + for _, tc := range fixture.AuthorizationCases { + t.Run(tc.Name, func(t *testing.T) { + err := AuthorizeAccountTarget(Actor{Role: Role(tc.ActorRole), OrganizationID: tc.ActorOrganizationID}, Account{Role: Role(tc.TargetRole), OrganizationID: tc.TargetOrganizationID}) + if tc.Allowed && err != nil { + t.Fatalf("unexpected error %v", err) + } + if !tc.Allowed && !IsKind(err, ErrorForbidden) { + t.Fatalf("error=%v want forbidden", err) + } + }) + } +} + +func TestCreateAccountValidatesAndScopesOrganizationAdministrator(t *testing.T) { + store := &fakeStore{organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}}} + service := testService(store) + actor := Actor{ID: "admin-1", Role: RoleOrganizationAdmin, OrganizationID: "org-1"} + account, err := service.CreateAccount(context.Background(), actor, CreateAccountInput{Phone: " 138 (0013)-8000 ", DisplayName: " User ", Password: "password8", Role: RoleUser, OrganizationID: "org-other"}) + if err != nil { + t.Fatal(err) + } + if account.Phone != "13800138000" || account.DisplayName != "User" || account.OrganizationID != "org-1" || account.SessionVersion != 1 { + t.Fatalf("account=%#v", account) + } + for _, input := range []CreateAccountInput{ + {Phone: "bad", DisplayName: "User", Password: "password8", Role: RoleUser}, + {Phone: "13800138001", DisplayName: " ", Password: "password8", Role: RoleUser}, + {Phone: "13800138001", DisplayName: "User", Password: "short", Role: RoleUser}, + {Phone: "13800138001", DisplayName: "User", Password: "password8", Role: RoleOrganizationAdmin}, + } { + _, err := service.CreateAccount(context.Background(), actor, input) + if !IsKind(err, ErrorValidation) && !IsKind(err, ErrorForbidden) { + t.Fatalf("input=%#v error=%v", input, err) + } + } +} + +func TestUpdateAccountSessionVersionContract(t *testing.T) { + fixture := loadContract(t) + for _, tc := range fixture.SessionVersionCases { + t.Run(tc.Name, func(t *testing.T) { + store := &fakeStore{accounts: map[string]Account{"user-1": {ID: "user-1", DisplayName: "Before", Role: RoleUser, OrganizationID: "org-1", Status: StatusActive, SessionVersion: 5}}, organizations: map[string]Organization{"org-1": {ID: "org-1", Status: StatusActive}, "org-2": {ID: "org-2", Status: StatusActive}}} + service := testService(store) + patch := UpdateAccountInput{ClearLoginLock: tc.ClearLoginLock} + if tc.Name == "display name only" { + value := "After" + patch.DisplayName = &value + } + if tc.RoleChanged { + value := RoleOrganizationAdmin + patch.Role = &value + } + if tc.OrganizationChanged { + value := "org-2" + patch.OrganizationID = &value + } + if tc.StatusChanged { + value := StatusDisabled + patch.Status = &value + } + if tc.PasswordChanged { + value := "password9" + patch.Password = &value + } + got, err := service.UpdateAccount(context.Background(), Actor{ID: "super", Role: RoleSuperAdmin}, "user-1", patch) + if err != nil { + t.Fatal(err) + } + want := 5 + if tc.Increment { + want++ + } + if got.SessionVersion != want { + t.Fatalf("sessionVersion=%d want %d", got.SessionVersion, want) + } + }) + } +} + +func TestOrganizationAdministrationAndDeleteConflict(t *testing.T) { + store := &fakeStore{organizations: map[string]Organization{}} + service := testService(store) + org, err := service.CreateOrganization(context.Background(), Actor{Role: RoleSuperAdmin}, " Acme ") + if err != nil || org.Name != "Acme" || org.ArchiveOwnerID != "archive:org-fixed" { + t.Fatalf("org=%#v err=%v", org, err) + } + if _, err := service.CreateOrganization(context.Background(), Actor{Role: RoleOrganizationAdmin}, "Nope"); !IsKind(err, ErrorForbidden) { + t.Fatalf("error=%v", err) + } + store.memberCount = 1 + if err := service.DeleteOrganization(context.Background(), Actor{Role: RoleSuperAdmin}, org.ID); !IsKind(err, ErrorConflict) { + t.Fatalf("error=%v", err) + } +} + +func TestInfrastructureErrorTaxonomy(t *testing.T) { + store := &fakeStore{err: errors.New("database unavailable")} + _, err := testService(store).ListAccounts(context.Background(), Actor{Role: RoleSuperAdmin}, AccountFilters{}) + if !IsKind(err, ErrorInfrastructure) || !errors.Is(err, store.err) { + t.Fatalf("error=%v", err) + } +} + +func loadContract(t *testing.T) contractFixture { + t.Helper() + raw, err := os.ReadFile("../../../contracts/admin/accounts-organizations-v1.json") + if err != nil { + t.Fatal(err) + } + var fixture contractFixture + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + return fixture +} + +func testService(store Store) *Service { + return NewService(store, WithIDGenerator(func(prefix string) string { return prefix + "-fixed" }), WithClock(func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) }), WithPasswordHasher(func(string) (PasswordHash, error) { return PasswordHash{Hash: "hash", Salt: "salt"}, nil })) +} + +type fakeStore struct { + accounts map[string]Account + organizations map[string]Organization + memberCount int + err error +} + +func (s *fakeStore) ListAccounts(context.Context, AccountFilters) ([]Account, error) { + if s.err != nil { + return nil, s.err + } + var out []Account + for _, a := range s.accounts { + out = append(out, a) + } + return out, nil +} +func (s *fakeStore) GetAccount(_ context.Context, id string) (Account, bool, error) { + a, ok := s.accounts[id] + return a, ok, s.err +} +func (s *fakeStore) CreateAccount(_ context.Context, a Account) (Account, error) { + if s.accounts == nil { + s.accounts = map[string]Account{} + } + s.accounts[a.ID] = a + return a, s.err +} +func (s *fakeStore) UpdateAccount(_ context.Context, a Account) (Account, error) { + s.accounts[a.ID] = a + return a, s.err +} +func (s *fakeStore) DeleteAccount(context.Context, string, string) error { return s.err } +func (s *fakeStore) ListOrganizations(context.Context, bool) ([]Organization, error) { + var out []Organization + for _, o := range s.organizations { + out = append(out, o) + } + return out, s.err +} +func (s *fakeStore) GetOrganization(_ context.Context, id string) (Organization, bool, error) { + o, ok := s.organizations[id] + return o, ok, s.err +} +func (s *fakeStore) CreateOrganization(_ context.Context, o Organization) (Organization, error) { + if s.organizations == nil { + s.organizations = map[string]Organization{} + } + s.organizations[o.ID] = o + return o, s.err +} +func (s *fakeStore) UpdateOrganization(_ context.Context, o Organization) (Organization, error) { + s.organizations[o.ID] = o + return o, s.err +} +func (s *fakeStore) DeleteOrganization(context.Context, string) error { return s.err } +func (s *fakeStore) CountOrganizationMembers(context.Context, string) (int, error) { + return s.memberCount, s.err +} diff --git a/backend/internal/postgres/administration.go b/backend/internal/postgres/administration.go new file mode 100644 index 0000000..998d2f9 --- /dev/null +++ b/backend/internal/postgres/administration.go @@ -0,0 +1,253 @@ +package postgres + +import ( + "context" + "database/sql" + "fmt" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration" +) + +const accountColumns = `id, phone, display_name, role, organization_id, status, password_hash, password_salt, failed_login_count, locked_until, session_version, last_login_at, legacy_subject, created_at, updated_at` +const organizationColumns = `id, name, status, archive_owner_id, created_at, updated_at` +const ListAdministrationAccountsSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE ($1::text = '' OR organization_id = $1::text) AND ($2::text = '' OR role = $2::text) AND ($3::boolean OR status = 'active') ORDER BY created_at DESC` +const GetAdministrationAccountSQL = `SELECT ` + accountColumns + ` FROM public.platform_users WHERE id = $1::text` +const CreateAdministrationAccountSQL = `INSERT INTO public.platform_users (` + accountColumns + `) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING ` + accountColumns +const UpdateAdministrationAccountSQL = `UPDATE public.platform_users SET phone=$2, display_name=$3, role=$4, organization_id=$5, status=$6, password_hash=$7, password_salt=$8, failed_login_count=$9, locked_until=$10, session_version=$11, last_login_at=$12, legacy_subject=$13, created_at=$14, updated_at=$15 WHERE id=$1::text RETURNING ` + accountColumns +const ListAdministrationOrganizationsSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE ($1::boolean OR status = 'active') ORDER BY created_at ASC` +const GetAdministrationOrganizationSQL = `SELECT ` + organizationColumns + ` FROM public.platform_organizations WHERE id = $1::text` +const CreateAdministrationOrganizationSQL = `INSERT INTO public.platform_organizations (` + organizationColumns + `) VALUES ($1,$2,$3,$4,$5,$6) RETURNING ` + organizationColumns +const UpdateAdministrationOrganizationSQL = `UPDATE public.platform_organizations SET name=$2, status=$3, archive_owner_id=$4, created_at=$5, updated_at=$6 WHERE id=$1::text RETURNING ` + organizationColumns +const CountAdministrationOrganizationMembersSQL = `SELECT count(*) FROM public.platform_users WHERE organization_id = $1::text` +const DeleteAdministrationOrganizationSQL = `DELETE FROM public.platform_organizations WHERE id = $1::text RETURNING id` +const ArchiveAssetsSQL = `UPDATE public.assets SET owner_id = $2::text WHERE owner_id = $1::text` +const ArchiveGenerationJobsSQL = `UPDATE public.generation_jobs SET owner_id = $2::text WHERE owner_id = $1::text` +const ArchiveProjectsSQL = `UPDATE public.projects SET owner_id = $2::text WHERE owner_id = $1::text` +const ArchiveImageTemplatesSQL = `UPDATE public.image_templates SET owner_id = $2::text WHERE owner_id = $1::text` +const DeleteAdministrationAccountSQL = `DELETE FROM public.platform_users WHERE id = $1::text` + +func (db *Database) administrationQuerier() (Querier, error) { + if db.config.Backend != BackendPostgres || db.querier == nil { + return nil, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + return db.querier, nil +} +func (db *Database) ListAccounts(ctx context.Context, f administration.AccountFilters) ([]administration.Account, error) { + q, e := db.administrationQuerier() + if e != nil { + return nil, e + } + rows, e := q.Query(ctx, ListAdministrationAccountsSQL, f.OrganizationID, f.Role, f.IncludeDisabled) + if e != nil { + return nil, fmt.Errorf("list administration accounts: %w", e) + } + defer rows.Close() + var out []administration.Account + for rows.Next() { + a, e := scanAdministrationAccount(rows) + if e != nil { + return nil, e + } + out = append(out, a) + } + return out, rows.Err() +} +func (db *Database) GetAccount(ctx context.Context, id string) (administration.Account, bool, error) { + q, e := db.administrationQuerier() + if e != nil { + return administration.Account{}, false, e + } + rows, e := q.Query(ctx, GetAdministrationAccountSQL, id) + if e != nil { + return administration.Account{}, false, fmt.Errorf("get administration account: %w", e) + } + defer rows.Close() + if !rows.Next() { + return administration.Account{}, false, rows.Err() + } + a, e := scanAdministrationAccount(rows) + return a, e == nil, e +} +func (db *Database) CreateAccount(ctx context.Context, a administration.Account) (administration.Account, error) { + return db.writeAccount(ctx, CreateAdministrationAccountSQL, a) +} +func (db *Database) UpdateAccount(ctx context.Context, a administration.Account) (administration.Account, error) { + return db.writeAccount(ctx, UpdateAdministrationAccountSQL, a) +} +func (db *Database) writeAccount(ctx context.Context, statement string, a administration.Account) (administration.Account, error) { + q, e := db.administrationQuerier() + if e != nil { + return administration.Account{}, e + } + rows, e := q.Query(ctx, statement, accountArgs(a)...) + if e != nil { + return administration.Account{}, administrationWriteError(e) + } + defer rows.Close() + if !rows.Next() { + return administration.Account{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "账号不存在。"} + } + return scanAdministrationAccount(rows) +} +func accountArgs(a administration.Account) []any { + return []any{a.ID, a.Phone, a.DisplayName, a.Role, optionalDatabaseText(a.OrganizationID), a.Status, a.PasswordHash, a.PasswordSalt, a.FailedLoginCount, a.LockedUntil, a.SessionVersion, a.LastLoginAt, optionalDatabaseText(a.LegacySubject), a.CreatedAt, a.UpdatedAt} +} +func scanAdministrationAccount(rows Rows) (administration.Account, error) { + var a administration.Account + var org, legacy sql.NullString + var locked, last sql.NullTime + if e := rows.Scan(&a.ID, &a.Phone, &a.DisplayName, &a.Role, &org, &a.Status, &a.PasswordHash, &a.PasswordSalt, &a.FailedLoginCount, &locked, &a.SessionVersion, &last, &legacy, &a.CreatedAt, &a.UpdatedAt); e != nil { + return a, fmt.Errorf("scan administration account: %w", e) + } + if org.Valid { + a.OrganizationID = org.String + } + if legacy.Valid { + a.LegacySubject = legacy.String + } + if locked.Valid { + a.LockedUntil = &locked.Time + } + if last.Valid { + a.LastLoginAt = &last.Time + } + return a, nil +} + +func (db *Database) ListOrganizations(ctx context.Context, include bool) ([]administration.Organization, error) { + q, e := db.administrationQuerier() + if e != nil { + return nil, e + } + rows, e := q.Query(ctx, ListAdministrationOrganizationsSQL, include) + if e != nil { + return nil, fmt.Errorf("list administration organizations: %w", e) + } + defer rows.Close() + var out []administration.Organization + for rows.Next() { + o, e := scanAdministrationOrganization(rows) + if e != nil { + return nil, e + } + out = append(out, o) + } + return out, rows.Err() +} +func (db *Database) GetOrganization(ctx context.Context, id string) (administration.Organization, bool, error) { + q, e := db.administrationQuerier() + if e != nil { + return administration.Organization{}, false, e + } + rows, e := q.Query(ctx, GetAdministrationOrganizationSQL, id) + if e != nil { + return administration.Organization{}, false, e + } + defer rows.Close() + if !rows.Next() { + return administration.Organization{}, false, rows.Err() + } + o, e := scanAdministrationOrganization(rows) + return o, e == nil, e +} +func (db *Database) CreateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) { + return db.writeOrganization(ctx, CreateAdministrationOrganizationSQL, o) +} +func (db *Database) UpdateOrganization(ctx context.Context, o administration.Organization) (administration.Organization, error) { + return db.writeOrganization(ctx, UpdateAdministrationOrganizationSQL, o) +} +func (db *Database) writeOrganization(ctx context.Context, statement string, o administration.Organization) (administration.Organization, error) { + q, e := db.administrationQuerier() + if e != nil { + return administration.Organization{}, e + } + rows, e := q.Query(ctx, statement, o.ID, o.Name, o.Status, o.ArchiveOwnerID, o.CreatedAt, o.UpdatedAt) + if e != nil { + return administration.Organization{}, administrationWriteError(e) + } + defer rows.Close() + if !rows.Next() { + return administration.Organization{}, &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"} + } + return scanAdministrationOrganization(rows) +} +func scanAdministrationOrganization(rows Rows) (administration.Organization, error) { + var o administration.Organization + if e := rows.Scan(&o.ID, &o.Name, &o.Status, &o.ArchiveOwnerID, &o.CreatedAt, &o.UpdatedAt); e != nil { + return o, fmt.Errorf("scan administration organization: %w", e) + } + return o, nil +} +func (db *Database) CountOrganizationMembers(ctx context.Context, id string) (int, error) { + q, e := db.administrationQuerier() + if e != nil { + return 0, e + } + rows, e := q.Query(ctx, CountAdministrationOrganizationMembersSQL, id) + if e != nil { + return 0, e + } + defer rows.Close() + if !rows.Next() { + return 0, fmt.Errorf("member count returned no row") + } + var count int + e = rows.Scan(&count) + return count, e +} +func (db *Database) DeleteOrganization(ctx context.Context, id string) error { + q, e := db.administrationQuerier() + if e != nil { + return e + } + rows, e := q.Query(ctx, DeleteAdministrationOrganizationSQL, id) + if e != nil { + return administrationWriteError(e) + } + defer rows.Close() + if !rows.Next() { + if e := rows.Err(); e != nil { + return e + } + return &administration.Error{Kind: administration.ErrorNotFound, Message: "组织不存在。"} + } + var deletedID string + if e := rows.Scan(&deletedID); e != nil { + return fmt.Errorf("scan deleted organization: %w", e) + } + return rows.Err() +} +func (db *Database) DeleteAccount(ctx context.Context, id, archive string) error { + if db.config.Backend != BackendPostgres || db.transactions == nil { + return fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend) + } + tx, e := db.transactions.Begin(ctx) + if e != nil { + return fmt.Errorf("begin delete account transaction: %w", e) + } + done := false + defer func() { + if !done { + _ = tx.Rollback(ctx) + } + }() + for _, statement := range []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL} { + if e = tx.Exec(ctx, statement, id, archive); e != nil { + return fmt.Errorf("archive account ownership: %w", e) + } + } + if e = tx.Exec(ctx, DeleteAdministrationAccountSQL, id); e != nil { + return fmt.Errorf("delete administration account: %w", e) + } + if e = tx.Commit(ctx); e != nil { + return fmt.Errorf("commit delete account transaction: %w", e) + } + done = true + return nil +} +func administrationWriteError(err error) error { + if sqlState(err) == "23505" { + return &administration.Error{Kind: administration.ErrorConflict, Message: "唯一字段已存在。", Err: err} + } + return err +} diff --git a/backend/internal/postgres/administration_test.go b/backend/internal/postgres/administration_test.go new file mode 100644 index 0000000..61dd6c7 --- /dev/null +++ b/backend/internal/postgres/administration_test.go @@ -0,0 +1,96 @@ +package postgres + +import ( + "context" + "errors" + "reflect" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration" +) + +func TestAdministrationListAccountsUsesExplicitColumnsAndParameterizedFilters(t *testing.T) { + rows := &identityRows{} + querier := &identityQuerier{rows: rows} + db := NewDatabase(Config{Backend: BackendPostgres}, querier) + _, err := db.ListAccounts(context.Background(), administration.AccountFilters{OrganizationID: "org-1", Role: administration.RoleUser, IncludeDisabled: true}) + if err != nil { + t.Fatal(err) + } + if querier.sql != ListAdministrationAccountsSQL { + t.Fatalf("SQL=%q", querier.sql) + } + if !reflect.DeepEqual(querier.args, []any{"org-1", administration.RoleUser, true}) { + t.Fatalf("args=%#v", querier.args) + } +} + +func TestDeleteAccountArchivesAllOwnedDataAndIdentityInOneTransaction(t *testing.T) { + tx := &administrationTransaction{} + db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx}) + if err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1"); err != nil { + t.Fatal(err) + } + wantSQL := []string{ArchiveAssetsSQL, ArchiveGenerationJobsSQL, ArchiveProjectsSQL, ArchiveImageTemplatesSQL, DeleteAdministrationAccountSQL} + if !reflect.DeepEqual(tx.sql, wantSQL) { + t.Fatalf("SQL sequence=%#v want %#v", tx.sql, wantSQL) + } + for _, args := range tx.args { + if !reflect.DeepEqual(args, []any{"user-1", "archive:org-1"}) && !reflect.DeepEqual(args, []any{"user-1"}) { + t.Fatalf("args=%#v", args) + } + } + if tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("commits=%d rollbacks=%d", tx.commits, tx.rollbacks) + } +} + +func TestDeleteAccountRollsBackArchiveTransactionFailure(t *testing.T) { + tx := &administrationTransaction{failAt: 3, err: errors.New("projects failed")} + db := NewDatabase(Config{Backend: BackendPostgres}, &administrationPool{tx: tx}) + err := db.DeleteAccount(context.Background(), "user-1", "archive:org-1") + if !errors.Is(err, tx.err) || tx.commits != 0 || tx.rollbacks != 1 { + t.Fatalf("err=%v commits=%d rollbacks=%d", err, tx.commits, tx.rollbacks) + } +} + +func TestAdministrationStoreFailsClosedWithoutPostgres(t *testing.T) { + db := NewDatabase(Config{Backend: BackendLocal}, &identityQuerier{}) + if _, err := db.ListOrganizations(context.Background(), true); err == nil { + t.Fatal("ListOrganizations error=nil") + } + if err := db.DeleteAccount(context.Background(), "u", "a"); err == nil { + t.Fatal("DeleteAccount error=nil") + } +} + +var _ administration.Store = (*Database)(nil) + +type administrationPool struct{ tx *administrationTransaction } + +func (p *administrationPool) Query(context.Context, string, ...any) (Rows, error) { + return nil, errors.New("outside transaction") +} +func (p *administrationPool) Begin(context.Context) (Transaction, error) { return p.tx, nil } + +type administrationTransaction struct { + sql []string + args [][]any + failAt int + err error + commits, rollbacks int +} + +func (t *administrationTransaction) Query(context.Context, string, ...any) (Rows, error) { + return nil, errors.New("unexpected query") +} +func (t *administrationTransaction) Exec(_ context.Context, query string, args ...any) error { + t.sql = append(t.sql, query) + t.args = append(t.args, args) + if t.failAt == len(t.sql) { + return t.err + } + return nil +} +func (t *administrationTransaction) Commit(context.Context) error { t.commits++; return nil } +func (t *administrationTransaction) Rollback(context.Context) error { t.rollbacks++; return nil } diff --git a/contracts/admin/accounts-organizations-v1.json b/contracts/admin/accounts-organizations-v1.json new file mode 100644 index 0000000..ad0f0fa --- /dev/null +++ b/contracts/admin/accounts-organizations-v1.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "phoneCases": [ + { "input": " +86 (138) 0013-8000 ", "normalized": "+8613800138000", "valid": true }, + { "input": "12345", "normalized": "12345", "valid": false }, + { "input": "13800abc000", "normalized": "13800abc000", "valid": false } + ], + "authorizationCases": [ + { "name": "super administrator manages any account", "actorRole": "super_admin", "actorOrganizationId": "org-1", "targetRole": "organization_admin", "targetOrganizationId": "org-2", "allowed": true }, + { "name": "organization administrator manages own ordinary user", "actorRole": "organization_admin", "actorOrganizationId": "org-1", "targetRole": "user", "targetOrganizationId": "org-1", "allowed": true }, + { "name": "organization administrator cannot manage peer administrator", "actorRole": "organization_admin", "actorOrganizationId": "org-1", "targetRole": "organization_admin", "targetOrganizationId": "org-1", "allowed": false }, + { "name": "organization administrator cannot cross organizations", "actorRole": "organization_admin", "actorOrganizationId": "org-1", "targetRole": "user", "targetOrganizationId": "org-2", "allowed": false } + ], + "sessionVersionCases": [ + { "name": "display name only", "roleChanged": false, "organizationChanged": false, "statusChanged": false, "passwordChanged": false, "clearLoginLock": false, "increment": false }, + { "name": "clear login lock only", "roleChanged": false, "organizationChanged": false, "statusChanged": false, "passwordChanged": false, "clearLoginLock": true, "increment": false }, + { "name": "role mutation", "roleChanged": true, "organizationChanged": false, "statusChanged": false, "passwordChanged": false, "clearLoginLock": false, "increment": true }, + { "name": "organization mutation", "roleChanged": false, "organizationChanged": true, "statusChanged": false, "passwordChanged": false, "clearLoginLock": false, "increment": true }, + { "name": "status mutation", "roleChanged": false, "organizationChanged": false, "statusChanged": true, "passwordChanged": false, "clearLoginLock": true, "increment": true }, + { "name": "password mutation", "roleChanged": false, "organizationChanged": false, "statusChanged": false, "passwordChanged": true, "clearLoginLock": true, "increment": true } + ], + "archiveTables": ["assets", "generation_jobs", "projects", "image_templates"] +} diff --git a/tests/admin-accounts-organizations-contract.test.ts b/tests/admin-accounts-organizations-contract.test.ts new file mode 100644 index 0000000..f478710 --- /dev/null +++ b/tests/admin-accounts-organizations-contract.test.ts @@ -0,0 +1,42 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { isValidPhone, normalizePhone } from "@/lib/server/account-store"; + +type Fixture = { + version: 1; + phoneCases: Array<{ input: string; normalized: string; valid: boolean }>; + authorizationCases: Array<{ actorRole: string; targetRole: string; allowed: boolean }>; + sessionVersionCases: Array<{ name: string; increment: boolean }>; + archiveTables: string[]; +}; + +const fixtureURL = new URL("../contracts/admin/accounts-organizations-v1.json", import.meta.url); + +describe("administration accounts and organizations v1 contract", () => { + it("shares phone normalization and validation with the real TypeScript store", async () => { + const fixture = JSON.parse(await readFile(fixtureURL, "utf8")) as Fixture; + for (const testCase of fixture.phoneCases) { + expect(normalizePhone(testCase.input)).toBe(testCase.normalized); + expect(isValidPhone(testCase.input)).toBe(testCase.valid); + } + }); + + it("freezes route authorization and mutation policy consumed by Go", async () => { + const fixture = JSON.parse(await readFile(fixtureURL, "utf8")) as Fixture; + const accountsRoute = await readFile(new URL("../app/api/admin/accounts/route.ts", import.meta.url), "utf8"); + const passwordRoute = await readFile(new URL("../app/api/admin/accounts/password/route.ts", import.meta.url), "utf8"); + const organizationRoute = await readFile(new URL("../app/api/admin/organizations/route.ts", import.meta.url), "utf8"); + const store = await readFile(new URL("../lib/server/account-store.ts", import.meta.url), "utf8"); + + expect(accountsRoute).toContain('target.role !== "user"'); + expect(accountsRoute).toContain("actor.organizationId !== target.organizationId"); + expect(passwordRoute).toContain('target.role !== "user"'); + expect(organizationRoute).toContain("requireSuperAdmin(session.user)"); + expect(store).toContain("sessionVersion: nextPassword || patch.role || patch.organizationId !== undefined || patch.status ? current.sessionVersion + 1 : current.sessionVersion"); + expect(fixture.authorizationCases.filter((item) => item.allowed)).toHaveLength(2); + expect(fixture.sessionVersionCases.filter((item) => item.increment).map((item) => item.name)).toEqual([ + "role mutation", "organization mutation", "status mutation", "password mutation" + ]); + for (const table of fixture.archiveTables) expect(store).toContain(`"${table}"`); + }); +});