feat(admin): 用户管理 - 支持管理员添加用户 (#104)
* feat(canvas): 绘图工具 - 支持自定义 tldraw License Key * docs(canvas): 绘图工具 - 添加 License Key 页面截图 * feat(admin): 用户管理 - 支持管理员添加用户
This commit is contained in:
@@ -179,6 +179,25 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, users)
|
||||
})
|
||||
r.POST("/admin/users", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
|
||||
var req service.CreateAdminUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
fail(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
created, err := svc.CreateAdminUser(user, req)
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
}
|
||||
ok(c, gin.H{"user": created})
|
||||
})
|
||||
r.GET("/admin/references", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type CreateAdminUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Role model.UserRole `json:"role"`
|
||||
Status model.UserStatus `json:"status"`
|
||||
}
|
||||
type UpdateUserRequest struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
@@ -186,6 +194,78 @@ func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
username := normalizeUsername(req.Username)
|
||||
email := normalizeEmail(req.Email)
|
||||
displayName := normalizeDisplayName(req.DisplayName, username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePassword(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if err := validateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548")
|
||||
}
|
||||
if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548")
|
||||
}
|
||||
if _, err := s.repo.UserByUsername(username); err == nil {
|
||||
return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if email != "" {
|
||||
if _, err := s.repo.UserByEmail(email); err == nil {
|
||||
return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
passwordHash, err := hashPassword(req.Password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
user := &model.User{
|
||||
ID: newID(),
|
||||
Username: username,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
Role: req.Role,
|
||||
Status: req.Status,
|
||||
PasswordHash: passwordHash,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := s.repo.Create(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureSignupBonus(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
account, err := s.repo.CreditAccount(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &AdminUser{
|
||||
User: *user,
|
||||
AvailableMicrocredits: account.AvailableMicrocredits,
|
||||
ReservedMicrocredits: account.ReservedMicrocredits,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) {
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -7,9 +7,68 @@ import (
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCreateAdminUserCreatesActiveUserWithPasswordAndAudit(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(&actor).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
created, err := (&Service{repo: repository.New(db)}).CreateAdminUser(&actor, CreateAdminUserRequest{
|
||||
Username: "new-user",
|
||||
DisplayName: "New User",
|
||||
Email: "new-user@example.com",
|
||||
Password: "strong-password",
|
||||
Role: model.UserRoleUser,
|
||||
Status: model.UserStatusActive,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created.Username != "new-user" || created.DisplayName != "New User" || created.Email != "new-user@example.com" {
|
||||
t.Fatalf("created user = %+v", created)
|
||||
}
|
||||
if created.Role != model.UserRoleUser || created.Status != model.UserStatusActive {
|
||||
t.Fatalf("created user role/status = %q/%q", created.Role, created.Status)
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(created.PasswordHash), []byte("strong-password")); err != nil {
|
||||
t.Fatalf("password hash does not match: %v", err)
|
||||
}
|
||||
var account model.CreditAccount
|
||||
if err := db.First(&account, "user_id = ?", created.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if account.AvailableMicrocredits <= 0 {
|
||||
t.Fatalf("available credits = %d, want positive signup bonus", account.AvailableMicrocredits)
|
||||
}
|
||||
var audit model.AdminAuditEvent
|
||||
if err := db.Where("action = ? AND target_id = ?", "user.create", created.ID).First(&audit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if audit.ActorUserID != actor.ID {
|
||||
t.Fatalf("audit actor = %q, want %q", audit.ActorUserID, actor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAdminUserRejectsDuplicateUsername(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
if err := db.Create(&actor).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &Service{repo: repository.New(db)}
|
||||
input := CreateAdminUserRequest{Username: "duplicate", DisplayName: "Duplicate", Password: "strong-password", Role: model.UserRoleUser, Status: model.UserStatusActive}
|
||||
if _, err := svc.CreateAdminUser(&actor, input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.CreateAdminUser(&actor, input); err == nil {
|
||||
t.Fatal("CreateAdminUser() duplicate username error = nil")
|
||||
}
|
||||
}
|
||||
func TestBulkDisableUsersDisablesUsersSessionsAndWritesAudits(t *testing.T) {
|
||||
db := newBulkUserTestDB(t)
|
||||
actor := model.User{ID: "admin-1", Username: "admin", Role: model.UserRoleAdmin, Status: model.UserStatusActive}
|
||||
@@ -95,7 +154,7 @@ func newBulkUserTestDB(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.AdminAuditEvent{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.SystemSetting{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
|
||||
Reference in New Issue
Block a user