feat: add administration domain core
This commit is contained in:
173
backend/internal/administration/model.go
Normal file
173
backend/internal/administration/model.go
Normal file
@@ -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}
|
||||||
|
}
|
||||||
82
backend/internal/administration/organizations.go
Normal file
82
backend/internal/administration/organizations.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
29
backend/internal/administration/password.go
Normal file
29
backend/internal/administration/password.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
218
backend/internal/administration/service.go
Normal file
218
backend/internal/administration/service.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
219
backend/internal/administration/service_test.go
Normal file
219
backend/internal/administration/service_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
253
backend/internal/postgres/administration.go
Normal file
253
backend/internal/postgres/administration.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
96
backend/internal/postgres/administration_test.go
Normal file
96
backend/internal/postgres/administration_test.go
Normal file
@@ -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 }
|
||||||
23
contracts/admin/accounts-organizations-v1.json
Normal file
23
contracts/admin/accounts-organizations-v1.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
42
tests/admin-accounts-organizations-contract.test.ts
Normal file
42
tests/admin-accounts-organizations-contract.test.ts
Normal file
@@ -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}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user