c091ad7aed
Stand up an internal ops-api service with operator account, auth, and audit log subsystems backed by a dedicated migrations_ops migration set. Wire Makefile targets and the migrate Dockerfile stage so the new schema ships alongside the existing tenant + monitoring databases. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
276 lines
7.7 KiB
Go
276 lines
7.7 KiB
Go
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
|
|
}
|