diff --git a/backend/internal/handler/admin/user_handler.go b/backend/internal/handler/admin/user_handler.go index b47310da1d..72f8f36f57 100644 --- a/backend/internal/handler/admin/user_handler.go +++ b/backend/internal/handler/admin/user_handler.go @@ -53,6 +53,7 @@ type CreateUserRequest struct { Password string `json:"password" binding:"required,min=6"` Username string `json:"username"` Notes string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` Balance *float64 `json:"balance"` Concurrency int `json:"concurrency"` RPMLimit int `json:"rpm_limit"` @@ -66,6 +67,7 @@ type UpdateUserRequest struct { Password string `json:"password" binding:"omitempty,min=6"` Username *string `json:"username"` Notes *string `json:"notes"` + Role string `json:"role" binding:"omitempty,oneof=admin user"` Balance *float64 `json:"balance"` Concurrency *int `json:"concurrency"` RPMLimit *int `json:"rpm_limit"` @@ -269,6 +271,7 @@ func (h *UserHandler) Create(c *gin.Context) { Password: req.Password, Username: req.Username, Notes: req.Notes, + Role: req.Role, Balance: req.Balance, Concurrency: req.Concurrency, RPMLimit: req.RPMLimit, @@ -297,12 +300,20 @@ func (h *UserHandler) Update(c *gin.Context) { return } + // 防锁死保护:管理员不能把自己降级为普通用户(单管理员场景下会失去后台访问权)。 + // 与既有"不能禁用/删除 admin"保护一致。降级其他管理员仍然允许。 + if req.Role == service.RoleUser && userID == getAdminIDFromContext(c) { + response.BadRequest(c, "cannot demote yourself from admin") + return + } + // 使用指针类型直接传递,nil 表示未提供该字段 user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{ Email: req.Email, Password: req.Password, Username: req.Username, Notes: req.Notes, + Role: req.Role, Balance: req.Balance, Concurrency: req.Concurrency, RPMLimit: req.RPMLimit, diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index b6377123b1..1152b14132 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -125,6 +125,7 @@ type CreateUserInput struct { Password string Username string Notes string + Role string // 空字符串表示使用默认角色(user);合法值 admin/user Balance *float64 Concurrency int RPMLimit int @@ -136,6 +137,7 @@ type UpdateUserInput struct { Password string Username *string Notes *string + Role string // 空字符串表示"未提供"(不修改);合法值 admin/user Balance *float64 // 使用指针区分"未提供"和"设置为0" Concurrency *int // 使用指针区分"未提供"和"设置为0" RPMLimit *int // 使用指针区分"未提供"和"设置为0" diff --git a/backend/internal/service/admin_service_role_test.go b/backend/internal/service/admin_service_role_test.go new file mode 100644 index 0000000000..26cf9279b2 --- /dev/null +++ b/backend/internal/service/admin_service_role_test.go @@ -0,0 +1,85 @@ +//go:build unit + +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAdminService_CreateUser_WithAdminRole(t *testing.T) { + repo := &userRepoStub{nextID: 30} + svc := &adminServiceImpl{userRepo: repo} + + user, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "admin@test.com", + Password: "strong-pass", + Role: RoleAdmin, + }) + require.NoError(t, err) + require.Equal(t, RoleAdmin, user.Role) +} + +func TestAdminService_CreateUser_DefaultsToUserRole(t *testing.T) { + repo := &userRepoStub{nextID: 31} + svc := &adminServiceImpl{userRepo: repo} + + user, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "plain@test.com", + Password: "strong-pass", + }) + require.NoError(t, err) + require.Equal(t, RoleUser, user.Role) +} + +func TestAdminService_CreateUser_InvalidRoleRejected(t *testing.T) { + repo := &userRepoStub{nextID: 32} + svc := &adminServiceImpl{userRepo: repo} + + _, err := svc.CreateUser(context.Background(), &CreateUserInput{ + Email: "bad@test.com", + Password: "strong-pass", + Role: "superuser", + }) + require.Error(t, err) + require.Empty(t, repo.created, "非法角色不应写入用户") +} + +func TestAdminService_UpdateUser_PromoteToAdmin(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}} + repo := &rpmUserRepoStub{userRepoStub: base} + invalidator := &authCacheInvalidatorStub{} + svc := &adminServiceImpl{ + userRepo: repo, + redeemCodeRepo: &redeemRepoStub{}, + authCacheInvalidator: invalidator, + } + + updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: RoleAdmin}) + require.NoError(t, err) + require.Equal(t, RoleAdmin, updated.Role) + require.Equal(t, []int64{42}, invalidator.userIDs, "角色变更应失效认证缓存") +} + +func TestAdminService_UpdateUser_RoleOmittedKeepsExisting(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleAdmin}} + repo := &rpmUserRepoStub{userRepoStub: base} + svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}} + + newName := "renamed" + updated, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Username: &newName}) + require.NoError(t, err) + require.Equal(t, RoleAdmin, updated.Role, "未提供 role 时不应改变现有角色") +} + +func TestAdminService_UpdateUser_InvalidRoleRejected(t *testing.T) { + base := &userRepoStub{user: &User{ID: 42, Email: "u@example.com", Role: RoleUser}} + repo := &rpmUserRepoStub{userRepoStub: base} + svc := &adminServiceImpl{userRepo: repo, redeemCodeRepo: &redeemRepoStub{}} + + _, err := svc.UpdateUser(context.Background(), 42, &UpdateUserInput{Role: "root"}) + require.Error(t, err) + require.Nil(t, repo.lastUpdated, "非法角色不应触发持久化") +} diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go index e95ed3d92e..9acd2deba7 100644 --- a/backend/internal/service/admin_user.go +++ b/backend/internal/service/admin_user.go @@ -105,6 +105,18 @@ func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) return s.userRepo.GetByIDIncludeDeleted(ctx, id) } +// normalizeUserRole 校验并归一化角色输入。 +// 空字符串返回 fallback(未提供时的默认角色);非法值返回错误。 +func normalizeUserRole(role, fallback string) (string, error) { + if role == "" { + return fallback, nil + } + if role != RoleAdmin && role != RoleUser { + return "", fmt.Errorf("invalid role: %q (must be %s or %s)", role, RoleAdmin, RoleUser) + } + return role, nil +} + func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { balance := 0.0 if input.Balance != nil { @@ -113,11 +125,17 @@ func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInpu balance = s.settingService.GetDefaultBalance(ctx) } + // 角色可由管理员在创建时指定(admin/user);未提供时默认 user。 + role, err := normalizeUserRole(input.Role, RoleUser) + if err != nil { + return nil, err + } + user := &User{ Email: input.Email, Username: input.Username, Notes: input.Notes, - Role: RoleUser, // Always create as regular user, never admin + Role: role, Balance: balance, Concurrency: input.Concurrency, RPMLimit: input.RPMLimit, @@ -197,6 +215,15 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda user.Status = input.Status } + // 角色变更(admin/user);空字符串表示不修改。 + if input.Role != "" { + role, err := normalizeUserRole(input.Role, user.Role) + if err != nil { + return nil, err + } + user.Role = role + } + if input.Concurrency != nil { user.Concurrency = *input.Concurrency } diff --git a/frontend/src/api/admin/users.ts b/frontend/src/api/admin/users.ts index 8ff022ff68..78b4f8177b 100644 --- a/frontend/src/api/admin/users.ts +++ b/frontend/src/api/admin/users.ts @@ -121,6 +121,7 @@ export async function create(userData: { password: string username?: string notes?: string + role?: 'admin' | 'user' balance?: number concurrency?: number rpm_limit?: number diff --git a/frontend/src/components/admin/user/UserCreateModal.vue b/frontend/src/components/admin/user/UserCreateModal.vue index a638e79cf5..609f7cd520 100644 --- a/frontend/src/components/admin/user/UserCreateModal.vue +++ b/frontend/src/components/admin/user/UserCreateModal.vue @@ -25,6 +25,13 @@ +