feat: 文章 CRUD 接口 JSON 化——/api/admin|my/articles

- 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 全绿
This commit is contained in:
2026-08-27 19:40:02 +08:00
parent a0061b14e3
commit 5b227b71ef
9 files changed
+181 -99

No files matched your search

+74 -62
View File
@@ -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)
}
}
+23 -15
View File
@@ -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)
}
}
+12 -7
View File
@@ -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))
+7
View File
@@ -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": "",
+21 -4
View File
@@ -5,13 +5,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="articleError" 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="articleForm" action="{{.FormAction}}" method="post" class="space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<!-- Hidden: attachment ownership (token on create, id on edit) -->
<input type="hidden" name="session_token" value="{{.SessionToken}}">
@@ -256,6 +254,25 @@ var easyMDE = new EasyMDE({
});
}
})();
// ---- Article form submitJSON 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.'); }
});
});
})();
</script>
{{end}}
+4 -3
View File
@@ -40,9 +40,10 @@
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<a href="/admin/articles/{{.ID}}/edit"
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/admin/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="inline blog-delete-form"
data-url="/api/admin/articles/{{.ID}}"
data-confirm="{{index $.Tr "article_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 "article_delete"}}</button>
</form>
+13
View File
@@ -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');
+23 -5
View File
@@ -6,13 +6,11 @@
<h2 class="text-3xl font-bold text-gray-900">{{.FormTitleText}}</h2>
</div>
{{if .Error}}
<div class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<div id="myArticleError" class="mb-6 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .Error}}hidden{{end}}">
{{.Error}}
</div>
{{end}}
<form action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<form id="myArticleForm" action="{{.FormAction}}" method="post" class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
{{if .SessionToken}}
<input type="hidden" name="session_token" value="{{.SessionToken}}">
@@ -90,8 +88,9 @@
</section>
<script>
var myEasyMDE = null;
document.addEventListener('DOMContentLoaded', function() {
var easyMDE = new EasyMDE({
myEasyMDE = new EasyMDE({
element: document.getElementById('content'),
spellChecker: false,
status: false,
@@ -102,6 +101,25 @@ document.addEventListener('DOMContentLoaded', function() {
"link", "image", "|", "preview", "side-by-side", "fullscreen", "|", "guide"]
});
});
// ---- Form submitJSON API ----
(function () {
var form = document.getElementById('myArticleForm');
if (!form) return;
var articleId = {{ if .FormArticleID }}{{ .FormArticleID }}{{ else }}0{{ end }};
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
var ta = document.getElementById('content');
if (ta && myEasyMDE) { ta.value = myEasyMDE.value(); }
var method = articleId ? 'PUT' : 'POST';
var url = articleId ? '/api/my/articles/' + articleId : '/api/my/articles';
blogAPI(method, url, blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/my/articles'; }
else { blogShowError('myArticleError', r.error || 'Failed to save article.'); }
});
});
})();
</script>
{{template "footer" .}}
+4 -3
View File
@@ -40,9 +40,10 @@
<td class="px-4 py-3 text-sm text-right whitespace-nowrap">
<a href="/my/articles/{{.ID}}/edit"
class="text-blue-600 hover:text-blue-800 font-medium mr-3">{{index $.Tr "article_edit"}}</a>
<form action="/my/articles/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "article_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="inline blog-delete-form"
data-url="/api/my/articles/{{.ID}}"
data-confirm="{{index $.Tr "article_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 "article_delete"}}</button>
</form>