package app import ( "context" "errors" "strings" "time" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" "github.com/geo-platform/tenant-api/internal/ops/domain" "github.com/geo-platform/tenant-api/internal/ops/repository" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( ActionAccountCreate = "account.create" ActionAccountDisable = "account.disable" ActionAccountEnable = "account.enable" ActionAccountResetPassword = "account.reset_password" ActionAccountUpdateProfile = "account.update_profile" ActionAccountChangePassword = "account.change_password" ) type AccountService struct { accounts *repository.AccountRepository audits *AuditService } func NewAccountService(accounts *repository.AccountRepository, audits *AuditService) *AccountService { return &AccountService{accounts: accounts, audits: audits} } type CreateInput struct { Username string Email string Password string DisplayName string Role string } type ListInput struct { Keyword string Status string Page int Size int } type ListResult struct { Items []OperatorView `json:"items"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` } type OperatorDetail struct { OperatorView LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } func toDetail(a *domain.OperatorAccount) OperatorDetail { return OperatorDetail{ OperatorView: ToOperatorView(a), LastLoginAt: a.LastLoginAt, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt, } } func (s *AccountService) List(ctx context.Context, in ListInput) (*ListResult, error) { page := in.Page if page <= 0 { page = 1 } size := in.Size if size <= 0 || size > 200 { size = 20 } items, total, err := s.accounts.List(ctx, repository.ListFilter{ Keyword: strings.TrimSpace(in.Keyword), Status: strings.TrimSpace(in.Status), Limit: size, Offset: (page - 1) * size, }) if err != nil { return nil, err } views := make([]OperatorView, 0, len(items)) for i := range items { views = append(views, ToOperatorView(&items[i])) } return &ListResult{Items: views, Total: total, Page: page, Size: size}, nil } func (s *AccountService) Get(ctx context.Context, id int64) (*OperatorDetail, error) { account, err := s.accounts.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return nil, response.ErrNotFound(40410, "account_not_found", "账号不存在") } return nil, err } d := toDetail(account) return &d, nil } func (s *AccountService) Create(ctx context.Context, actor *Actor, in CreateInput) (*OperatorDetail, error) { username := strings.TrimSpace(in.Username) if username == "" { return nil, response.ErrBadRequest(40010, "invalid_username", "用户名不能为空") } if len(in.Password) < 8 { return nil, response.ErrBadRequest(40011, "weak_password", "密码至少 8 位") } displayName := strings.TrimSpace(in.DisplayName) if displayName == "" { displayName = username } role := strings.TrimSpace(in.Role) if role == "" { role = domain.RoleAdmin } hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { return nil, err } var emailPtr *string if email := strings.TrimSpace(in.Email); email != "" { emailPtr = &email } var creatorID *int64 if actor != nil { id := actor.OperatorID creatorID = &id } account, err := s.accounts.Create(ctx, repository.CreateAccountInput{ Username: username, Email: emailPtr, PasswordHash: string(hash), DisplayName: displayName, Role: role, CreatedBy: creatorID, }) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, response.ErrConflict(40910, "duplicate_account", "用户名或邮箱已存在") } return nil, err } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAccountCreate, "operator_account", account.ID, map[string]any{ "username": account.Username, "display_name": account.DisplayName, "role": account.Role, })) } d := toDetail(account) return &d, nil } func (s *AccountService) SetStatus(ctx context.Context, actor *Actor, id int64, status string) error { if status != domain.StatusActive && status != domain.StatusDisabled { return response.ErrBadRequest(40020, "invalid_status", "状态值无效") } if actor != nil && actor.OperatorID == id && status == domain.StatusDisabled { return response.ErrBadRequest(40021, "cannot_disable_self", "不能停用自己的账号") } if err := s.accounts.UpdateStatus(ctx, id, status); err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return response.ErrNotFound(40410, "account_not_found", "账号不存在") } return err } action := ActionAccountEnable if status == domain.StatusDisabled { action = ActionAccountDisable } if actor != nil { _ = s.audits.Append(ctx, actor.audit(action, "operator_account", id, nil)) } return nil } func (s *AccountService) ResetPassword(ctx context.Context, actor *Actor, id int64, newPassword string) error { if len(newPassword) < 8 { return response.ErrBadRequest(40011, "weak_password", "密码至少 8 位") } hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { return err } if err := s.accounts.UpdatePassword(ctx, id, string(hash)); err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return response.ErrNotFound(40410, "account_not_found", "账号不存在") } return err } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAccountResetPassword, "operator_account", id, nil)) } return nil } type UpdateProfileInput struct { DisplayName string Email string } func (s *AccountService) UpdateProfile(ctx context.Context, actor *Actor, id int64, in UpdateProfileInput) error { displayName := strings.TrimSpace(in.DisplayName) if displayName == "" { return response.ErrBadRequest(40012, "invalid_display_name", "显示名称不能为空") } var emailPtr *string if email := strings.TrimSpace(in.Email); email != "" { emailPtr = &email } if err := s.accounts.UpdateProfile(ctx, id, displayName, emailPtr); err != nil { if errors.Is(err, repository.ErrAccountNotFound) { return response.ErrNotFound(40410, "account_not_found", "账号不存在") } return err } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAccountUpdateProfile, "operator_account", id, map[string]any{ "display_name": displayName, "email": emailPtr, })) } return nil } type ChangeOwnPasswordInput struct { OldPassword string NewPassword string } func (s *AccountService) ChangeOwnPassword(ctx context.Context, actor *Actor, in ChangeOwnPasswordInput) error { if actor == nil { return response.ErrUnauthorized(40101, "missing_actor", "未登录") } if len(in.NewPassword) < 8 { return response.ErrBadRequest(40011, "weak_password", "新密码至少 8 位") } account, err := s.accounts.GetByID(ctx, actor.OperatorID) if err != nil { return response.ErrNotFound(40410, "account_not_found", "账号不存在") } if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.OldPassword)); err != nil { return response.ErrUnauthorized(40111, "invalid_old_password", "原密码错误") } hash, err := bcrypt.GenerateFromPassword([]byte(in.NewPassword), bcrypt.DefaultCost) if err != nil { return err } if err := s.accounts.UpdatePassword(ctx, actor.OperatorID, string(hash)); err != nil { return err } _ = s.audits.Append(ctx, actor.audit(ActionAccountChangePassword, "operator_account", actor.OperatorID, nil)) return nil }