feat(user): require email when admin creates a user

- Add `email` as a required field in `createUserRequest`
- Enforce email format verification and database uniqueness checks in the admin user creation handler
- Update the admin user creation frontend modal with validation and form field
- Update the corresponding backend unit tests and regenerate Swagger docs
This commit is contained in:
ryan
2026-06-13 16:23:07 +08:00
parent 5b13d5b464
commit 922f764241
8 changed files with 115 additions and 0 deletions
+5
View File
@@ -6286,10 +6286,15 @@ const docTemplate = `{
"user.createUserRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
},
"is_active": {
"type": "boolean"
},
+5
View File
@@ -6279,10 +6279,15 @@
"user.createUserRequest": {
"type": "object",
"required": [
"email",
"password",
"username"
],
"properties": {
"email": {
"type": "string",
"maxLength": 255
},
"is_active": {
"type": "boolean"
},
+4
View File
@@ -987,6 +987,9 @@ definitions:
type: object
user.createUserRequest:
properties:
email:
maxLength: 255
type: string
is_active:
type: boolean
is_admin:
@@ -1003,6 +1006,7 @@ definitions:
minLength: 3
type: string
required:
- email
- password
- username
type: object
@@ -13,6 +13,7 @@ const emptyForm: CreateUserRequest = {
username: "",
password: "",
nickname: "",
email: "",
is_active: true,
is_admin: false,
}
@@ -46,6 +47,12 @@ export function CreateUserModal({
newErrors.username = "用户名长度不能少于 3 位"
}
if (!form.email.trim()) {
newErrors.email = "邮箱不能为空"
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
newErrors.email = "邮箱格式不正确"
}
if (!form.password) {
newErrors.password = "密码不能为空"
} else if (form.password.length < 8) {
@@ -66,6 +73,7 @@ export function CreateUserModal({
...form,
username: form.username.trim(),
nickname: form.nickname?.trim() || undefined,
email: form.email.trim(),
})
onClose()
} catch {
@@ -109,6 +117,20 @@ export function CreateUserModal({
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
value={form.email}
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
placeholder="请输入邮箱地址"
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email}</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="password">密码</Label>
<Input
+2
View File
@@ -340,6 +340,8 @@ export interface CreateUserRequest {
password: string;
/** 昵称 */
nickname?: string;
/** 邮箱 */
email: string;
/** 是否激活 */
is_active?: boolean;
/** 是否管理员 */
+2
View File
@@ -16,4 +16,6 @@ const (
usernameRequired = "用户名不能为空"
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
createUserFailed = "创建用户失败"
emailRequired = "邮箱不能为空"
emailExists = "邮箱已被注册"
)
+17
View File
@@ -314,6 +314,7 @@ type createUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=64"`
Password string `json:"password" binding:"required,min=8,max=64"`
Nickname string `json:"nickname" binding:"omitempty,max=64"`
Email string `json:"email" binding:"required,email,max=255"`
IsActive bool `json:"is_active"`
IsAdmin bool `json:"is_admin"`
}
@@ -342,11 +343,16 @@ func CreateUser(c *gin.Context) {
req.Username = strings.TrimSpace(req.Username)
req.Nickname = strings.TrimSpace(req.Nickname)
req.Password = strings.TrimSpace(req.Password)
req.Email = strings.TrimSpace(req.Email)
if req.Username == "" {
c.JSON(http.StatusBadRequest, util.Err(usernameRequired))
return
}
if req.Email == "" {
c.JSON(http.StatusBadRequest, util.Err(emailRequired))
return
}
if len(req.Password) < minPasswordLength {
c.JSON(http.StatusBadRequest, util.Err(passwordTooShort))
return
@@ -363,10 +369,21 @@ func CreateUser(c *gin.Context) {
return
}
var emailCount int64
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&emailCount).Error; err != nil {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
if emailCount > 0 {
c.JSON(http.StatusBadRequest, util.Err(emailExists))
return
}
newUser := model.User{
ID: idgen.NextUint64ID(),
Username: req.Username,
Nickname: req.Nickname,
Email: req.Email,
IsActive: req.IsActive,
IsAdmin: req.IsAdmin,
LastLoginAt: time.Time{},
+58
View File
@@ -313,6 +313,7 @@ func TestCreateUser(t *testing.T) {
Username: "newuser",
Password: "newpassword123",
Nickname: "New Nickname",
Email: "newuser@example.com",
IsActive: true,
IsAdmin: false,
}
@@ -350,6 +351,9 @@ func TestCreateUser(t *testing.T) {
if err := dbConn.Where("username = ?", "newuser").First(&dbUser).Error; err != nil {
t.Fatalf("failed to find user in db: %v", err)
}
if dbUser.Email != "newuser@example.com" {
t.Errorf("expected email 'newuser@example.com', got '%s'", dbUser.Email)
}
if !dbUser.CheckPassword("newpassword123") {
t.Error("password was not hashed correctly")
}
@@ -361,6 +365,7 @@ func TestCreateUser(t *testing.T) {
ID: 2001,
Username: "dupuser",
Nickname: "Dup User",
Email: "dupuser@example.com",
}
dbConn.Create(&existing)
@@ -368,6 +373,7 @@ func TestCreateUser(t *testing.T) {
Username: "dupuser",
Password: "password123",
Nickname: "Another Nick",
Email: "another@example.com",
IsActive: true,
}
body, _ := json.Marshal(payload)
@@ -387,10 +393,44 @@ func TestCreateUser(t *testing.T) {
}
})
t.Run("create user with duplicate email", func(t *testing.T) {
existing := model.User{
ID: 2002,
Username: "existingemail",
Nickname: "Existing Email",
Email: "dupemail@example.com",
}
dbConn.Create(&existing)
payload := createUserRequest{
Username: "newuser2",
Password: "password123",
Nickname: "New User 2",
Email: "dupemail@example.com",
IsActive: true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
var resp util.ResponseAny
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if resp.ErrorMsg != emailExists {
t.Errorf("expected error '%s', got '%s'", emailExists, resp.ErrorMsg)
}
})
t.Run("validation error - password too short", func(t *testing.T) {
payload := createUserRequest{
Username: "shortpass",
Password: "123",
Email: "shortpass@example.com",
IsActive: true,
}
body, _ := json.Marshal(payload)
@@ -403,6 +443,24 @@ func TestCreateUser(t *testing.T) {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
})
t.Run("validation error - invalid email format", func(t *testing.T) {
payload := map[string]interface{}{
"username": "bademail",
"password": "password123",
"email": "not-an-email",
"is_active": true,
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "/api/v1/admin/users", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 Bad Request, got %d. Body: %s", w.Code, w.Body.String())
}
})
}
func TestDeleteUser(t *testing.T) {