feat: add administration domain core

This commit is contained in:
2026-08-13 16:32:07 +08:00
parent 065dcd955a
commit cea2751dc5
9 changed files with 1135 additions and 0 deletions

View 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}
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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 }

View 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"]
}

View 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}"`);
});
});