From 5b227b71ef83ed53ec8b7499c827f6d9900d8c5d Mon Sep 17 00:00:00 2001 From: kevin Date: Thu, 27 Aug 2026 19:40:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=87=E7=AB=A0=20CRUD=20=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=20JSON=20=E5=8C=96=E2=80=94=E2=80=94/api/admin|my/art?= =?UTF-8?q?icles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - article.go:articleForm 加 json tags,新增 parseArticleFormJSON(绑定+ 去白);ArticleCreate/ArticleUpdate/ArticleDelete 增加 redirectPath 参数, 校验失败改 APIError 400、DB 错误 500,成功 {ok,redirect}(原 renderForm 分支移除);id 改用 parseUintParam 数值化 - my_articles.go:MyArticleCreate 复用 ArticleCreate(db, /my/articles); MyArticleUpdate/MyArticleDelete 保留 author_id 所有权约束(404 article_not_found);my 更新不触碰标签(表单无该字段,与旧行为一致) - main.go:admin/my 文章旧 POST 路由移除,改 POST/PUT/DELETE /api/admin/articles[/:id] 与 /api/my/articles[/:id] - 模板:article_create.html/my_article_form.html 表单改 blogAPI 提交 (easyMDE.value() 同步正文、editor e.submitter 分流草稿/发布),错误内联 articleError/myArticleError;article_list.html/my_articles.html 删除改 blogDelete 委托(base.html 新公共函数,DELETE + reload 保筛选状态) - main_test 冒烟补文章 CRUD 断言;go build/vet/test 全绿 --- handlers/article.go | 136 +++++++++++++++------------- handlers/my_articles.go | 38 +++++--- main.go | 19 ++-- main_test.go | 7 ++ templates/admin/article_create.html | 25 ++++- templates/admin/article_list.html | 7 +- templates/layouts/base.html | 13 +++ templates/user/my_article_form.html | 28 +++++- templates/user/my_articles.html | 7 +- 9 files changed, 181 insertions(+), 99 deletions(-) diff --git a/handlers/article.go b/handlers/article.go index 7f8f3ad..99ed9b9 100644 --- a/handlers/article.go +++ b/handlers/article.go @@ -56,7 +56,7 @@ func generateSlug(title string) string { } // fallbackSlug 在由标题派生的 slug 为空时返回非空 slug -//(例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID, +// (例如标题只包含标点/空白,或全部被剔除)。可用时使用文章 ID, // 否则使用短令牌。 func fallbackSlug(id uint) string { if id > 0 { @@ -65,41 +65,44 @@ func fallbackSlug(id uint) string { return "post-" + randomToken()[:8] } -// articleForm 保存解析后的文章表单字段,由创建和编辑处理器 -// 及其校验错误回填路径共用。 +// articleForm 是文章创建/更新接口的 JSON 请求体。 +// Action/TitleText/ArticleID 仅由页面渲染路径使用,不参与 JSON 绑定。 type articleForm struct { - Title string - Slug string - Summary string - Content string - Cover string - StatusStr string - IsTop bool - PublishedAt string // datetime-local 格式:"2006-01-02T15:04" - Tags string // 逗号分隔的标签名 - Action string // 表单提交 URL - TitleText string // 页面标题文字(创建还是编辑) - ArticleID uint // 已有文章 ID(编辑页面);创建时为 0 - SessionToken string // 待处理附件的归属令牌(创建页面) + Title string `json:"title"` + Slug string `json:"slug"` + Summary string `json:"summary"` + Content string `json:"content"` + Cover string `json:"cover"` + StatusStr string `json:"status"` // "0" 草稿、"1" 已发布 + IsTop bool `json:"is_top"` + PublishedAt string `json:"published_at"` // datetime-local:"2006-01-02T15:04" + Tags string `json:"tags"` // 逗号分隔的标签名 + SessionToken string `json:"session_token"` + Action string // 表单提交 URL(页面渲染用) + TitleText string // 页面标题文字(创建还是编辑) + ArticleID uint // 已有文章 ID(编辑页面);创建时为 0 } -// parseArticleForm 从请求中读取并去除空白后的文章表单字段。 -func parseArticleForm(c *gin.Context) articleForm { - return articleForm{ - Title: strings.TrimSpace(c.PostForm("title")), - Slug: strings.TrimSpace(c.PostForm("slug")), - Summary: strings.TrimSpace(c.PostForm("summary")), - Content: strings.TrimSpace(c.PostForm("content")), - Cover: strings.TrimSpace(c.PostForm("cover")), - StatusStr: c.PostForm("status"), - IsTop: c.PostForm("is_top") == "1", - PublishedAt: strings.TrimSpace(c.PostForm("published_at")), - Tags: strings.TrimSpace(c.PostForm("tags")), +// parseArticleFormJSON 绑定 JSON 请求体并去除空白后的文章字段。 +// 绑定失败时已写入 400 响应并返回 ok=false。 +func parseArticleFormJSON(c *gin.Context) (articleForm, bool) { + var f articleForm + if !bindJSON(c, &f) { + return f, false } + f.Title = strings.TrimSpace(f.Title) + f.Slug = strings.TrimSpace(f.Slug) + f.Summary = strings.TrimSpace(f.Summary) + f.Content = strings.TrimSpace(f.Content) + f.Cover = strings.TrimSpace(f.Cover) + f.PublishedAt = strings.TrimSpace(f.PublishedAt) + f.Tags = strings.TrimSpace(f.Tags) + f.SessionToken = strings.TrimSpace(f.SessionToken) + return f, true } // applyFormToData 将表单字段值写入模板数据映射,使渲染时表单被重新填充 -//(初次加载或校验错误)。 +// (初次加载或校验错误)。 func applyFormToData(data gin.H, f articleForm) { data["FormTitle"] = f.Title data["FormSlug"] = f.Slug @@ -261,22 +264,22 @@ func ArticleCreatePage(db *gorm.DB) gin.HandlerFunc { } } -// ArticleCreate 处理创建新文章的 POST 请求。 -func ArticleCreate(db *gorm.DB) gin.HandlerFunc { +// ArticleCreate 处理创建新文章的 POST 请求(admin 与 my 共用)。 +// redirectPath 是成功跳转目标(admin 用 /admin,普通用户用 /my/articles)。 +func ArticleCreate(db *gorm.DB, redirectPath string) gin.HandlerFunc { return func(c *gin.Context) { - tr := getTr(c) - f := parseArticleForm(c) - f.Action = "/admin/articles/new" - f.TitleText = tr["article_create_title"] - f.SessionToken = strings.TrimSpace(c.PostForm("session_token")) + f, ok := parseArticleFormJSON(c) + if !ok { + return + } // 校验必填字段。 if f.Title == "" { - renderArticleForm(c, db, f, tr["article_title_required"]) + APIError(c, http.StatusBadRequest, "article_title_required") return } if f.Content == "" { - renderArticleForm(c, db, f, tr["article_content_required"]) + APIError(c, http.StatusBadRequest, "article_content_required") return } @@ -294,7 +297,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { authorID, ok := sessionAuthorID(c) if !ok { - c.Redirect(http.StatusFound, "/login") + APIError(c, http.StatusUnauthorized, "api_unauthorized") return } @@ -321,7 +324,7 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { } if err := db.Create(&article).Error; err != nil { - renderArticleForm(c, db, f, tr["article_error"]) + APIError(c, http.StatusInternalServerError, "article_error") return } @@ -345,8 +348,8 @@ func ArticleCreate(db *gorm.DB) gin.HandlerFunc { _ = BindPendingAttachments(db, f.SessionToken, article.ID) } - // 成功:重定向到管理后台。 - c.Redirect(http.StatusFound, "/admin") + // 成功:跳转到文章列表。 + APIOK(c, redirectPath, gin.H{"article_id": article.ID}) } } @@ -395,28 +398,33 @@ func ArticleEditPage(db *gorm.DB) gin.HandlerFunc { } } -// ArticleUpdate 处理更新现有文章的 POST 请求。 -func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { +// ArticleUpdate 处理更新现有文章的请求(admin 与 my 共用)。 +// redirectPath 是成功跳转目标(admin 用 /admin/articles,普通用户用 /my/articles)。 +func ArticleUpdate(db *gorm.DB, redirectPath string) gin.HandlerFunc { return func(c *gin.Context) { - tr := getTr(c) - id := c.Param("id") - - var article models.Article - if err := db.First(&article, "id = ?", id).Error; err != nil { - c.Redirect(http.StatusFound, "/admin/articles") + id := parseUintParam(c, "id") + if id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") return } - f := parseArticleForm(c) - f.Action = "/admin/articles/" + id + "/edit" - f.TitleText = tr["article_edit_title"] + var article models.Article + if err := db.First(&article, id).Error; err != nil { + APIError(c, http.StatusNotFound, "article_not_found") + return + } + + f, ok := parseArticleFormJSON(c) + if !ok { + return + } if f.Title == "" { - renderArticleForm(c, db, f, tr["article_title_required"]) + APIError(c, http.StatusBadRequest, "article_title_required") return } if f.Content == "" { - renderArticleForm(c, db, f, tr["article_content_required"]) + APIError(c, http.StatusBadRequest, "article_content_required") return } @@ -456,7 +464,7 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { } if err := db.Model(&article).Updates(updates).Error; err != nil { - renderArticleForm(c, db, f, tr["article_error"]) + APIError(c, http.StatusInternalServerError, "article_error") return } @@ -466,15 +474,19 @@ func ArticleUpdate(db *gorm.DB) gin.HandlerFunc { // 记录错误但不影响文章更新 } - c.Redirect(http.StatusFound, "/admin/articles") + APIOK(c, redirectPath, nil) } } -// ArticleDelete 软删除文章(GORM 填充 DeletedAt)并重定向回管理列表。 -func ArticleDelete(db *gorm.DB) gin.HandlerFunc { +// ArticleDelete 软删除文章(GORM 填充 DeletedAt)。 +func ArticleDelete(db *gorm.DB, redirectPath string) gin.HandlerFunc { return func(c *gin.Context) { - id := c.Param("id") - db.Delete(&models.Article{}, "id = ?", id) - c.Redirect(http.StatusFound, "/admin/articles") + id := parseUintParam(c, "id") + if id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } + db.Delete(&models.Article{}, id) + APIOK(c, redirectPath, nil) } } diff --git a/handlers/my_articles.go b/handlers/my_articles.go index d35c5ed..409861d 100644 --- a/handlers/my_articles.go +++ b/handlers/my_articles.go @@ -48,7 +48,7 @@ func MyArticleCreatePage(db *gorm.DB) gin.HandlerFunc { // MyArticleCreate 处理普通用户创建新文章的 POST 请求。 func MyArticleCreate(db *gorm.DB) gin.HandlerFunc { - return ArticleCreate(db) // 复用相同的逻辑 + return ArticleCreate(db, "/my/articles") // 复用创建逻辑,跳转到 my 列表 } // MyArticleEditPage 为已登录用户自己的文章渲染编辑表单。 @@ -81,31 +81,34 @@ func MyArticleEditPage(db *gorm.DB) gin.HandlerFunc { } } -// MyArticleUpdate 处理更新用户自己文章的 POST 请求。 +// MyArticleUpdate 处理更新用户自己文章的请求(带 author_id 所有权约束)。 func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - tr := getTr(c) - id := c.Param("id") + id := parseUintParam(c, "id") + if id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } session := sessions.Default(c) userID := session.Get("user_id") var article models.Article if err := db.First(&article, "id = ? AND author_id = ?", id, userID).Error; err != nil { - c.Redirect(http.StatusFound, "/my/articles") + APIError(c, http.StatusNotFound, "article_not_found") return } - f := parseArticleForm(c) - f.Action = "/my/articles/" + id + "/edit" - f.TitleText = tr["article_edit_title"] - f.ArticleID = article.ID + f, ok := parseArticleFormJSON(c) + if !ok { + return + } if f.Title == "" { - renderMyArticleForm(c, db, f, tr["article_title_required"]) + APIError(c, http.StatusBadRequest, "article_title_required") return } if f.Content == "" { - renderMyArticleForm(c, db, f, tr["article_content_required"]) + APIError(c, http.StatusBadRequest, "article_content_required") return } @@ -145,23 +148,28 @@ func MyArticleUpdate(db *gorm.DB) gin.HandlerFunc { } if err := db.Model(&article).Updates(updates).Error; err != nil { - renderMyArticleForm(c, db, f, tr["article_error"]) + APIError(c, http.StatusInternalServerError, "article_error") return } - c.Redirect(http.StatusFound, "/my/articles") + // 注意:my 表单无 tags 字段,此处不触碰标签(与旧行为一致)。 + APIOK(c, "/my/articles", nil) } } // MyArticleDelete 软删除用户自己的文章。 func MyArticleDelete(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { - id := c.Param("id") + id := parseUintParam(c, "id") + if id == 0 { + APIError(c, http.StatusBadRequest, "api_invalid_request") + return + } session := sessions.Default(c) userID := session.Get("user_id") db.Where("id = ? AND author_id = ?", id, userID).Delete(&models.Article{}) - c.Redirect(http.StatusFound, "/my/articles") + APIOK(c, "/my/articles", nil) } } diff --git a/main.go b/main.go index dd151a8..bdfec83 100644 --- a/main.go +++ b/main.go @@ -182,10 +182,15 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi admin.GET("", handlers.AdminDashboard(db)) admin.GET("/articles", handlers.ArticleListPage(db)) admin.GET("/articles/new", handlers.ArticleCreatePage(db)) - admin.POST("/articles/new", handlers.ArticleCreate(db)) admin.GET("/articles/:id/edit", handlers.ArticleEditPage(db)) - admin.POST("/articles/:id/edit", handlers.ArticleUpdate(db)) - admin.POST("/articles/:id/delete", handlers.ArticleDelete(db)) + } + + adminArticleAPI := router.Group("/api/admin/articles") + adminArticleAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db)) + { + adminArticleAPI.POST("", handlers.ArticleCreate(db, "/admin")) + adminArticleAPI.PUT("/:id", handlers.ArticleUpdate(db, "/admin/articles")) + adminArticleAPI.DELETE("/:id", handlers.ArticleDelete(db, "/admin/articles")) } // 受保护的后台评论管理路由(仅管理员角色)。 @@ -270,16 +275,16 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi { myArticles.GET("/articles", handlers.MyArticlesPage(db)) myArticles.GET("/articles/new", handlers.MyArticleCreatePage(db)) - myArticles.POST("/articles/new", handlers.MyArticleCreate(db)) myArticles.GET("/articles/:id/edit", handlers.MyArticleEditPage(db)) - myArticles.POST("/articles/:id/edit", handlers.MyArticleUpdate(db)) - myArticles.POST("/articles/:id/delete", handlers.MyArticleDelete(db)) } - // 用户文章的受保护附件 API(仅登录用户)。 + // 用户文章的受保护 API(仅登录用户,含 attachments 静态段与 :id 参数段)。 myAPI := router.Group("/api/my/articles") myAPI.Use(middleware.AuthRequired(db)) { + myAPI.POST("", handlers.MyArticleCreate(db)) + myAPI.PUT("/:id", handlers.MyArticleUpdate(db)) + myAPI.DELETE("/:id", handlers.MyArticleDelete(db)) myAPI.POST("/attachments", handlers.UploadAttachment(db, cfg.Path)) myAPI.DELETE("/attachments/:id", handlers.DeleteAttachment(db, cfg.Path)) myAPI.GET("/:id/attachments", handlers.ListAttachments(db)) diff --git a/main_test.go b/main_test.go index eeea2e1..e6fa633 100644 --- a/main_test.go +++ b/main_test.go @@ -151,6 +151,13 @@ func TestRegisterRoutesSmoke(t *testing.T) { "POST /api/admin/comments/:id/approve": "", "POST /api/admin/comments/:id/reject": "", "POST /api/admin/comments/:id/delete": "", + // 文章 CRUD API。 + "POST /api/admin/articles": "", + "PUT /api/admin/articles/:id": "", + "DELETE /api/admin/articles/:id": "", + "POST /api/my/articles": "", + "PUT /api/my/articles/:id": "", + "DELETE /api/my/articles/:id": "", // 搬移的附件/头像端点。 "POST /api/admin/articles/attachments": "", "DELETE /api/admin/articles/attachments/:id": "", diff --git a/templates/admin/article_create.html b/templates/admin/article_create.html index e981135..a6c7f4d 100644 --- a/templates/admin/article_create.html +++ b/templates/admin/article_create.html @@ -5,13 +5,11 @@

{{.FormTitleText}}

- {{if .Error}} -
+
{{.Error}}
- {{end}} -
+ @@ -256,6 +254,25 @@ var easyMDE = new EasyMDE({ }); } })(); + +// ---- Article form submit(JSON API;草稿/发布按钮由 e.submitter 分流) ---- +(function () { + var form = document.getElementById('articleForm'); + if (!form) return; + form.addEventListener('submit', function (e) { + e.preventDefault(); + var btn = e.submitter || null; + // EasyMDE 隐藏了 textarea:先同步编辑器内容再提交。 + var ta = document.getElementById('articleContent'); + if (ta && easyMDE) { ta.value = easyMDE.value(); } + var method = articleID ? 'PUT' : 'POST'; + var url = articleID ? '/api/admin/articles/' + articleID : '/api/admin/articles'; + blogAPI(method, url, blogForm(form, btn)).then(function (r) { + if (r.ok) { window.location.href = r.redirect || '/admin'; } + else { blogShowError('articleError', r.error || 'Failed to save article.'); } + }); + }); +})(); {{end}} diff --git a/templates/admin/article_list.html b/templates/admin/article_list.html index d084b6d..8113307 100644 --- a/templates/admin/article_list.html +++ b/templates/admin/article_list.html @@ -40,9 +40,10 @@ {{index $.Tr "article_edit"}} - - +
diff --git a/templates/layouts/base.html b/templates/layouts/base.html index d7ad4f0..c0c3e05 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -235,6 +235,19 @@ } }); + // 列表删除提交(onsubmit = "return blogDelete(this)"): + // DELETE data-url 指向的 JSON 接口,成功后 reload(保留筛选状态)。 + window.blogDelete = function (form) { + var url = form.getAttribute('data-url'); + var confirmText = form.getAttribute('data-confirm'); + if (confirmText && !confirm(confirmText)) return false; + blogAPI('DELETE', url).then(function (r) { + if (r.ok) { window.location.reload(); } + else { alert(r.error || 'Failed'); } + }); + return false; + }; + function toggleDropdown() { var menu = document.getElementById('dropdownMenu'); menu.classList.toggle('hidden'); diff --git a/templates/user/my_article_form.html b/templates/user/my_article_form.html index b58a045..f2e9c85 100644 --- a/templates/user/my_article_form.html +++ b/templates/user/my_article_form.html @@ -6,13 +6,11 @@

{{.FormTitleText}}

- {{if .Error}} -
+
{{.Error}}
- {{end}} -
+ {{if .SessionToken}} @@ -90,8 +88,9 @@
{{template "footer" .}} diff --git a/templates/user/my_articles.html b/templates/user/my_articles.html index 1bac1cf..9d27cfc 100644 --- a/templates/user/my_articles.html +++ b/templates/user/my_articles.html @@ -40,9 +40,10 @@ {{index $.Tr "article_edit"}} - - +