Files
geo/server/internal/ops/app/account.go
T
root c091ad7aed feat(server/ops-api): add operations console backend
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>
2026-04-28 11:32:50 +08:00

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
}