219 lines
7.5 KiB
Go
219 lines
7.5 KiB
Go
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
|
|
}
|