feat: 用户管理接口 JSON 化——/api/admin/users CRUD

- admin_user.go:userForm 加 json tags,parseUserFormJSON(绑定+去白,
  ID 取路由参数);UserCreate/Update/Delete 校验错误改 APIError
  (400 校验、409 user_username_exists、403 自防/最后管理员、404
  user_not_found、500),成功 {ok,redirect:?saved=1&msg=...}
- i18n:新增 user_not_found(中英)
- main.go:旧 POST /admin/users/new|:id/edit|:id/delete 移除,
  改 POST/PUT/DELETE /api/admin/users[/:id](自防屏障保留)
- user_form.html:表单改 blogAPI(FormID 分流 POST/PUT),错误内联
- user_list.html:删除改 blogDelete 委托
- p2/security 测试:环境路由同步 + 用户校验/注入用例改 JSON 断言
  (TestAdminUserPasswordAndEmailEnforcement、TestAdminUserRoutesRejectNonNumericIDs)
- main_test 冒烟补用户 API 断言;go build/vet/test 全绿
This commit is contained in:
2026-08-27 19:44:09 +08:00
parent 5b227b71ef
commit 856d414295
8 files changed
+133 -140

No files matched your search

+57 -72
View File
@@ -13,17 +13,18 @@ import (
"gorm.io/gorm"
)
// userForm 保存提交的用户字段,外加共享创建/编辑表单模板的渲染元数据。
// userForm 是后台用户创建/更新接口的 JSON 请求体,
// 同时供共享创建/编辑表单模板的渲染使用(IsEdit/Action/TitleText 非绑定字段)。
type userForm struct {
ID uint
Username string
Password string
DisplayName string
Email string
Gender string
Birthday string // 来自日期输入的 YYYY-MM-DD
Role string
Status int
ID uint `json:"-"`
Username string `json:"username"`
Password string `json:"password"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // 来自日期输入的 YYYY-MM-DD
Role string `json:"role"`
Status int `json:"status"`
IsEdit bool
Action string
TitleText string
@@ -39,27 +40,21 @@ type userListView struct {
StatusBadge string
}
// parseUserForm 从请求中读取用户表单字段
func parseUserForm(c *gin.Context) userForm {
// 状态始终由 <select>0/1/2/3)发送;缺省视为正常,
// 但显式的 0(禁用)必须保留。
status := models.StatusNormal
if raw := strings.TrimSpace(c.PostForm("status")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil {
status = n
}
}
return userForm{
ID: uintFormID(c.Param("id")),
Username: strings.TrimSpace(c.PostForm("username")),
Password: c.PostForm("password"),
DisplayName: strings.TrimSpace(c.PostForm("display_name")),
Email: strings.TrimSpace(c.PostForm("email")),
Gender: strings.TrimSpace(c.PostForm("gender")),
Birthday: strings.TrimSpace(c.PostForm("birthday")),
Role: strings.TrimSpace(c.PostForm("role")),
Status: status,
// parseUserFormJSON 绑定 JSON 请求体的用户字段并去除空白
// 绑定失败时已写入 400 响应并返回 ok=false。ID 来自路由参数。
func parseUserFormJSON(c *gin.Context) (userForm, bool) {
var f userForm
if !bindJSON(c, &f) {
return f, false
}
f.ID = uintFormID(c.Param("id"))
f.Username = strings.TrimSpace(f.Username)
f.DisplayName = strings.TrimSpace(f.DisplayName)
f.Email = strings.TrimSpace(f.Email)
f.Gender = strings.TrimSpace(f.Gender)
f.Birthday = strings.TrimSpace(f.Birthday)
f.Role = strings.TrimSpace(f.Role)
return f, true
}
// uintFormID 将路由 :id 解析为 uint(不存在/非法时为 0)。
@@ -194,28 +189,27 @@ func UserCreatePage(db *gorm.DB) gin.HandlerFunc {
// UserCreate 处理 POST 创建新用户。
func UserCreate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
f := parseUserForm(c)
f.Action = "/admin/users/new"
f.TitleText = tr["user_create_title"]
f.IsEdit = false
f, ok := parseUserFormJSON(c)
if !ok {
return
}
if f.Username == "" {
renderUserForm(c, f, tr["user_username_required"])
APIError(c, http.StatusBadRequest, "user_username_required")
return
}
if f.Password == "" {
renderUserForm(c, f, tr["user_password_required"])
APIError(c, http.StatusBadRequest, "user_password_required")
return
}
// SECURITY (#23):执行平台最小密码长度。
if !validatePassword(f.Password) {
renderUserForm(c, f, tr["user_password_short"])
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
// SECURITY (#24):拒绝格式非法的邮箱地址。
if !validateEmail(f.Email) {
renderUserForm(c, f, tr["user_email_invalid"])
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
if f.Role == "" {
@@ -226,7 +220,7 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
var exists int64
db.Model(&models.User{}).Where("username = ?", f.Username).Count(&exists)
if exists > 0 {
renderUserForm(c, f, tr["user_username_exists"])
APIError(c, http.StatusConflict, "user_username_exists")
return
}
@@ -242,14 +236,14 @@ func UserCreate(db *gorm.DB) gin.HandlerFunc {
user.Birthday = &t
}
if err := user.SetPassword(f.Password); err != nil {
renderUserForm(c, f, tr["article_error"])
APIError(c, http.StatusInternalServerError, "article_error")
return
}
if err := db.Create(&user).Error; err != nil {
renderUserForm(c, f, tr["article_error"])
APIError(c, http.StatusInternalServerError, "article_error")
return
}
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=created")
APIOK(c, "/admin/users?saved=1&msg=created", gin.H{"user_id": user.ID})
}
}
@@ -293,20 +287,19 @@ func UserEditPage(db *gorm.DB) gin.HandlerFunc {
// UserUpdate 处理 POST 更新现有用户。
func UserUpdate(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
f := parseUserForm(c)
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
if f.ID == 0 {
c.Redirect(http.StatusFound, "/admin/users")
f, ok := parseUserFormJSON(c)
if !ok {
return
}
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
if f.ID == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
f.IsEdit = true
f.Action = fmt.Sprintf("/admin/users/%d/edit", f.ID)
f.TitleText = tr["user_edit_title"]
var user models.User
if err := db.First(&user, f.ID).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users")
APIError(c, http.StatusNotFound, "user_not_found")
return
}
@@ -316,11 +309,11 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
// SECURITY (#23/#24):在进行任何其他修改前校验提交的密码/邮箱——
// 密码重置或个人资料编辑必须遵守与注册相同的规则。
if f.Password != "" && !validatePassword(f.Password) {
renderUserForm(c, f, tr["user_password_short"])
APIError(c, http.StatusBadRequest, "user_password_short")
return
}
if !validateEmail(f.Email) {
renderUserForm(c, f, tr["user_email_invalid"])
APIError(c, http.StatusBadRequest, "user_email_invalid")
return
}
@@ -329,15 +322,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
// 自我保护:不能禁用/锁定自己的账户。
if isSelf && f.Status != models.StatusNormal {
f.DisplayName = user.DisplayName
f.Email = user.Email
f.Gender = user.Gender
f.Role = user.Role
f.Status = user.Status
if user.Birthday != nil {
f.Birthday = user.Birthday.Format("2006-01-02")
}
renderUserForm(c, f, tr["user_cannot_disable_self"])
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
@@ -346,7 +331,7 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
}
@@ -368,16 +353,16 @@ func UserUpdate(db *gorm.DB) gin.HandlerFunc {
// 可选密码重置(留空表示保持当前密码)。
if f.Password != "" {
if err := user.SetPassword(f.Password); err != nil {
renderUserForm(c, f, tr["article_error"])
APIError(c, http.StatusInternalServerError, "article_error")
return
}
}
if err := db.Save(&user).Error; err != nil {
renderUserForm(c, f, tr["article_error"])
APIError(c, http.StatusInternalServerError, "article_error")
return
}
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=updated")
APIOK(c, "/admin/users?saved=1&msg=updated", nil)
}
}
@@ -387,20 +372,20 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc {
// SECURITY (#19):在触碰 GORM 前拒绝非数值 id(参见 UserEditPage)。
targetID := uintFormID(c.Param("id"))
if targetID == 0 {
c.Redirect(http.StatusFound, "/admin/users")
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
}
currentID := userIDFromSession(c)
var user models.User
if err := db.First(&user, targetID).Error; err != nil {
c.Redirect(http.StatusFound, "/admin/users")
APIError(c, http.StatusNotFound, "user_not_found")
return
}
// 不能删除自己。
if targetID == currentID {
c.Redirect(http.StatusFound, "/admin/users?error=self_disable")
APIError(c, http.StatusForbidden, "user_cannot_disable_self")
return
}
// 不能删除最后一位管理员。
@@ -408,12 +393,12 @@ func UserDelete(db *gorm.DB) gin.HandlerFunc {
var adminCount int64
db.Model(&models.User{}).Where("role = ?", models.RoleAdmin).Count(&adminCount)
if adminCount <= 1 {
c.Redirect(http.StatusFound, "/admin/users?error=last_admin")
APIError(c, http.StatusForbidden, "user_cannot_remove_last_admin")
return
}
}
db.Delete(&user)
c.Redirect(http.StatusFound, "/admin/users?saved=1&msg=deleted")
APIOK(c, "/admin/users?saved=1&msg=deleted", nil)
}
}
+22 -38
View File
@@ -164,27 +164,19 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
token := e.csrfTokenFor(t, admin)
// 创建:短密码被拒绝(不创建任何行)。
fields := url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "ab")
fields.Set("role", models.RoleAuthor)
w := postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with short password: status = %d, want 200 (re-render)", w.Code)
}
if !strings.Contains(w.Body.String(), "6 characters") {
t.Fatal("short-password error message not rendered")
create := gin.H{"username": "charlie", "password": "ab", "role": models.RoleAuthor}
w := postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
t.Fatalf("create with short password: status = %d, code = %q, want 400/user_password_short",
w.Code, respCode(w))
}
// 创建:非法邮箱被拒绝。
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "abc")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("create with invalid email: status = %d, want 200", w.Code)
create["password"] = "longenough"
create["email"] = "abc"
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
t.Fatalf("create with invalid email: status = %d, code = %q", w.Code, respCode(w))
}
var count int64
@@ -194,14 +186,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
}
// 创建:合法数据成功。
fields = url.Values{}
fields.Set("username", "charlie")
fields.Set("password", "longenough")
fields.Set("email", "charlie@example.com")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, "/admin/users/new", admin, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("create valid user: status = %d", w.Code)
create["email"] = "charlie@example.com"
w = postJSON(e, http.MethodPost, "/api/admin/users", admin, token, create)
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("create valid user: status = %d, body %s", w.Code, w.Body.String())
}
e.db.Model(&models.User{}).Where("username = ?", "charlie").Count(&count)
if count != 1 {
@@ -210,12 +198,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
// 更新(密码重置路径):短密码被拒绝,哈希保持不变。
aliceID := userIDByUsername(t, e.db, "alice")
fields = url.Values{}
fields.Set("password", "x")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with short password: status = %d, want 200", w.Code)
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
gin.H{"password": "x", "role": models.RoleAuthor})
if w.Code != http.StatusBadRequest || respCode(w) != "user_password_short" {
t.Fatalf("update with short password: status = %d, code = %q", w.Code, respCode(w))
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
@@ -226,12 +212,10 @@ func TestAdminUserPasswordAndEmailEnforcement(t *testing.T) {
}
// 更新:非法邮箱被拒绝,旧值保留。
fields = url.Values{}
fields.Set("email", "bad")
fields.Set("role", models.RoleAuthor)
w = postForm(e, http.MethodPost, fmt.Sprintf("/admin/users/%d/edit", aliceID), admin, token, fields)
if w.Code != http.StatusOK {
t.Fatalf("update with invalid email: status = %d, want 200", w.Code)
w = postJSON(e, http.MethodPut, fmt.Sprintf("/api/admin/users/%d", aliceID), admin, token,
gin.H{"email": "bad", "role": models.RoleAuthor})
if w.Code != http.StatusBadRequest || respCode(w) != "user_email_invalid" {
t.Fatalf("update with invalid email: status = %d, code = %q", w.Code, respCode(w))
}
e.db.Where("username = ?", "alice").First(&u)
if u.Email != "" {
+16 -20
View File
@@ -120,13 +120,17 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
// 后台用户管理路由(SQL 注入回归覆盖,#19)。
admin := r.Group("/admin", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
admin.POST("/users/new", UserCreate(db))
admin.GET("/users/:id/edit", UserEditPage(db))
admin.POST("/users/:id/edit", UserUpdate(db))
admin.POST("/users/:id/delete", UserDelete(db))
admin.GET("/comments", CommentListPage(db))
}
usersAPI := r.Group("/api/admin/users", middleware.AuthRequired(db), middleware.AdminRequired(db))
{
usersAPI.POST("", UserCreate(db))
usersAPI.PUT("/:id", UserUpdate(db))
usersAPI.DELETE("/:id", UserDelete(db))
}
return &securityTestEnv{router: r, db: db, storageDir: storageDir, limiter: limiter}
}
@@ -473,25 +477,17 @@ func TestAdminUserRoutesRejectNonNumericIDs(t *testing.T) {
t.Fatalf("GET edit with id %q: location = %q, want /admin/users", id, loc)
}
// POST 更新不得修改任何内容(尝试提权)。
form := url.Values{}
form.Set("_csrf", token)
form.Set("role", models.RoleAdmin)
form.Set("status", "1")
form.Set("display_name", "hacked")
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/edit", admin,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusFound || w.Header().Get("Location") != "/admin/users" {
t.Fatalf("POST edit with id %q: status = %d, location = %q", id, w.Code, w.Header().Get("Location"))
// PUT 更新不得修改任何内容(尝试提权)。
w = postJSON(e, http.MethodPut, "/api/admin/users/"+url.PathEscape(id), admin, token,
gin.H{"role": models.RoleAdmin, "status": 1, "display_name": "hacked"})
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
t.Fatalf("PUT edit with id %q: status = %d, code = %q", id, w.Code, respCode(w))
}
// POST 删除不得删除任何内容。
form = url.Values{}
form.Set("_csrf", token)
w = e.do(http.MethodPost, "/admin/users/"+url.PathEscape(id)+"/delete", admin,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusFound {
t.Fatalf("POST delete with id %q: status = %d, want 302", id, w.Code)
// DELETE 删除不得删除任何内容。
w = postJSON(e, http.MethodDelete, "/api/admin/users/"+url.PathEscape(id), admin, token, nil)
if w.Code != http.StatusBadRequest || respCode(w) != "api_invalid_request" {
t.Fatalf("DELETE user with id %q: status = %d, code = %q", id, w.Code, respCode(w))
}
}
+2
View File
@@ -450,6 +450,7 @@ var translations = map[Lang]map[string]string{
"api_unauthorized": "Please sign in first.",
"api_forbidden": "You do not have permission to perform this action.",
"api_invalid_request": "Invalid request body.",
"user_not_found": "User not found.",
},
ZH: {
// 导航
@@ -885,6 +886,7 @@ var translations = map[Lang]map[string]string{
"api_unauthorized": "请先登录。",
"api_forbidden": "您没有权限执行此操作。",
"api_invalid_request": "请求参数格式不正确。",
"user_not_found": "用户不存在。",
},
}
+8 -3
View File
@@ -214,10 +214,15 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
{
users.GET("", handlers.UserListPage(db))
users.GET("/new", handlers.UserCreatePage(db))
users.POST("/new", handlers.UserCreate(db))
users.GET("/:id/edit", handlers.UserEditPage(db))
users.POST("/:id/edit", handlers.UserUpdate(db))
users.POST("/:id/delete", handlers.UserDelete(db))
}
usersAPI := router.Group("/api/admin/users")
usersAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
usersAPI.POST("", handlers.UserCreate(db))
usersAPI.PUT("/:id", handlers.UserUpdate(db))
usersAPI.DELETE("/:id", handlers.UserDelete(db))
}
// 受保护的后台文章附件 API / 路由(仅管理员角色)。
+4
View File
@@ -158,6 +158,10 @@ func TestRegisterRoutesSmoke(t *testing.T) {
"POST /api/my/articles": "",
"PUT /api/my/articles/:id": "",
"DELETE /api/my/articles/:id": "",
// 用户 CRUD API。
"POST /api/admin/users": "",
"PUT /api/admin/users/:id": "",
"DELETE /api/admin/users/:id": "",
// 搬移的附件/头像端点。
"POST /api/admin/articles/attachments": "",
"DELETE /api/admin/articles/attachments/:id": "",
+20 -4
View File
@@ -3,13 +3,11 @@
<section class="max-w-3xl mx-auto px-4 py-10">
<h2 class="text-2xl font-bold text-gray-900 mb-8">{{.FormTitleText}}</h2>
{{if .Error}}
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm">
<div id="userFormError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 text-sm {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
{{end}}
<form action="{{.FormAction}}" method="post" class="space-y-6">
<form id="userForm" action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Username (read-only on edit) -->
<div>
@@ -103,4 +101,22 @@
</form>
</section>
{{template "footer" .}}
<script>
(function () {
var form = document.getElementById('userForm');
if (!form) return;
var userId = {{ .FormID }};
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
var method = userId ? 'PUT' : 'POST';
var url = userId ? '/api/admin/users/' + userId : '/api/admin/users';
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/admin/users'; }
else { blogShowError('userFormError', r.error || 'Failed to save user.'); }
});
});
})();
</script>
{{end}}
+4 -3
View File
@@ -59,9 +59,10 @@
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<a href="/admin/users/{{.ID}}/edit"
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/users/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "user_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="inline blog-delete-form"
data-url="/api/admin/users/{{.ID}}"
data-confirm="{{index $.Tr "user_delete_confirm"}}"
onsubmit="return blogDelete(this)">
<button type="submit"
class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "settings_delete"}}</button>
</form>