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>
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
)
|
||||
|
||||
type Actor struct {
|
||||
OperatorID int64
|
||||
Username string
|
||||
DisplayName string
|
||||
Role string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func (a *Actor) audit(action, targetType string, targetID int64, metadata map[string]any) domain.AuditEvent {
|
||||
id := a.OperatorID
|
||||
var tt, ti string
|
||||
tt = targetType
|
||||
if targetID != 0 {
|
||||
ti = formatInt64(targetID)
|
||||
}
|
||||
return domain.AuditEvent{
|
||||
OperatorID: &id,
|
||||
OperatorName: a.DisplayName,
|
||||
Action: action,
|
||||
TargetType: tt,
|
||||
TargetID: ti,
|
||||
Metadata: metadata,
|
||||
IP: a.IP,
|
||||
UserAgent: a.UserAgent,
|
||||
RequestID: a.RequestID,
|
||||
}
|
||||
}
|
||||
|
||||
type actorContextKey struct{}
|
||||
|
||||
func WithActor(ctx context.Context, actor *Actor) context.Context {
|
||||
return context.WithValue(ctx, actorContextKey{}, actor)
|
||||
}
|
||||
|
||||
func ActorFromContext(ctx context.Context) *Actor {
|
||||
if actor, ok := ctx.Value(actorContextKey{}).(*Actor); ok {
|
||||
return actor
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatInt64(n int64) string {
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
const digits = "0123456789"
|
||||
if n < 0 {
|
||||
return "-" + formatInt64(-n)
|
||||
}
|
||||
buf := make([]byte, 0, 20)
|
||||
for n > 0 {
|
||||
buf = append([]byte{digits[n%10]}, buf...)
|
||||
n /= 10
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
)
|
||||
|
||||
type AuditService struct {
|
||||
repo *repository.AuditRepository
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewAuditService(repo *repository.AuditRepository, logger *zap.Logger) *AuditService {
|
||||
return &AuditService{repo: repo, logger: logger}
|
||||
}
|
||||
|
||||
func (s *AuditService) Append(ctx context.Context, e domain.AuditEvent) error {
|
||||
if err := s.repo.Append(ctx, e); err != nil {
|
||||
s.logger.Warn("ops audit append failed",
|
||||
zap.String("action", e.Action),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuditService) List(ctx context.Context, f domain.AuditLogFilter) ([]domain.AuditLog, int64, error) {
|
||||
return s.repo.List(ctx, f)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
type OperatorClaims struct {
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenIssuer struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
}
|
||||
|
||||
func NewTokenIssuer(secret string, accessTTL time.Duration) *TokenIssuer {
|
||||
return &TokenIssuer{secret: []byte(secret), accessTTL: accessTTL}
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) AccessTTL() time.Duration { return t.accessTTL }
|
||||
|
||||
type IssuedToken struct {
|
||||
AccessToken string
|
||||
ExpiresAt int64
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, error) {
|
||||
now := time.Now()
|
||||
exp := now.Add(t.accessTTL)
|
||||
claims := OperatorClaims{
|
||||
OperatorID: operator.ID,
|
||||
Username: operator.Username,
|
||||
DisplayName: operator.DisplayName,
|
||||
Role: operator.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: uuid.New().String(),
|
||||
Subject: "ops-access",
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign ops token: %w", err)
|
||||
}
|
||||
return &IssuedToken{AccessToken: signed, ExpiresAt: exp.Unix()}, nil
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(raw, &OperatorClaims{}, func(tok *jwt.Token) (any, error) {
|
||||
if _, ok := tok.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"])
|
||||
}
|
||||
return t.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*OperatorClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid ops token")
|
||||
}
|
||||
if claims.Subject != "ops-access" {
|
||||
return nil, errors.New("wrong token subject")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
accounts *repository.AccountRepository
|
||||
audits *AuditService
|
||||
issuer *TokenIssuer
|
||||
}
|
||||
|
||||
func NewAuthService(accounts *repository.AccountRepository, audits *AuditService, issuer *TokenIssuer) *AuthService {
|
||||
return &AuthService{accounts: accounts, audits: audits, issuer: issuer}
|
||||
}
|
||||
|
||||
type LoginInput struct {
|
||||
Username string
|
||||
Password string
|
||||
IP string
|
||||
UserAgent string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
AccessToken string
|
||||
ExpiresAt int64
|
||||
Operator OperatorView
|
||||
}
|
||||
|
||||
type OperatorView struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email *string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func ToOperatorView(a *domain.OperatorAccount) OperatorView {
|
||||
return OperatorView{
|
||||
ID: a.ID,
|
||||
Username: a.Username,
|
||||
DisplayName: a.DisplayName,
|
||||
Email: a.Email,
|
||||
Role: a.Role,
|
||||
Status: a.Status,
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
ActionLoginSuccess = "auth.login.success"
|
||||
ActionLoginFailed = "auth.login.failure"
|
||||
)
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, in LoginInput) (*LoginResult, error) {
|
||||
account, err := s.accounts.GetByUsername(ctx, in.Username)
|
||||
if err != nil {
|
||||
s.recordLoginFailure(ctx, in, "account_not_found")
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||
}
|
||||
|
||||
if !account.IsActive() {
|
||||
s.recordLoginFailure(ctx, in, "account_disabled")
|
||||
return nil, response.ErrForbidden(40310, "account_disabled", "账号已停用")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(in.Password)); err != nil {
|
||||
s.recordLoginFailure(ctx, in, "wrong_password")
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "账号或密码错误")
|
||||
}
|
||||
|
||||
token, err := s.issuer.Issue(account)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue token: %w", err)
|
||||
}
|
||||
|
||||
if err := s.accounts.MarkLogin(ctx, account.ID); err != nil {
|
||||
// non-fatal — login already succeeded
|
||||
_ = err
|
||||
}
|
||||
|
||||
id := account.ID
|
||||
_ = s.audits.Append(ctx, domain.AuditEvent{
|
||||
OperatorID: &id,
|
||||
OperatorName: account.DisplayName,
|
||||
Action: ActionLoginSuccess,
|
||||
IP: in.IP,
|
||||
UserAgent: in.UserAgent,
|
||||
RequestID: in.RequestID,
|
||||
})
|
||||
|
||||
return &LoginResult{
|
||||
AccessToken: token.AccessToken,
|
||||
ExpiresAt: token.ExpiresAt,
|
||||
Operator: ToOperatorView(account),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AuthService) recordLoginFailure(ctx context.Context, in LoginInput, reason string) {
|
||||
_ = s.audits.Append(ctx, domain.AuditEvent{
|
||||
Action: ActionLoginFailed,
|
||||
IP: in.IP,
|
||||
UserAgent: in.UserAgent,
|
||||
RequestID: in.RequestID,
|
||||
Metadata: map[string]any{
|
||||
"username": in.Username,
|
||||
"reason": reason,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
)
|
||||
|
||||
type DefaultAdminSeed struct {
|
||||
Username string
|
||||
Password string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
|
||||
// SeedDefaultAdmin inserts a single admin account when the operator_accounts
|
||||
// table is empty. It is safe to call on every startup — a non-empty table is
|
||||
// a no-op. Returns an error only when the table is empty AND the seed config
|
||||
// is incomplete (so a fresh deployment cannot silently boot without any
|
||||
// way to log in).
|
||||
func SeedDefaultAdmin(ctx context.Context, accounts *repository.AccountRepository, seed DefaultAdminSeed, logger *zap.Logger) error {
|
||||
count, err := accounts.Count(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count operator accounts: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
username := strings.TrimSpace(seed.Username)
|
||||
password := seed.Password
|
||||
if username == "" || password == "" {
|
||||
return errors.New("operator_accounts is empty but default_admin.username/password are not configured (set OPS_DEFAULT_ADMIN_USERNAME / OPS_DEFAULT_ADMIN_PASSWORD)")
|
||||
}
|
||||
displayName := strings.TrimSpace(seed.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = username
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash default admin password: %w", err)
|
||||
}
|
||||
|
||||
var emailPtr *string
|
||||
if email := strings.TrimSpace(seed.Email); email != "" {
|
||||
emailPtr = &email
|
||||
}
|
||||
|
||||
_, err = accounts.Create(ctx, repository.CreateAccountInput{
|
||||
Username: username,
|
||||
Email: emailPtr,
|
||||
PasswordHash: string(hash),
|
||||
DisplayName: displayName,
|
||||
Role: domain.RoleAdmin,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("create default admin: %w", err)
|
||||
}
|
||||
logger.Sugar().Infof("ops default admin seeded: username=%s", username)
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user