feat: 评论接口 JSON 化——/api/article/:slug/comments 与 admin 审核操作

- comment.go:commentForm 加 json tag;PostComment 校验分支改 APIError
  (404 article_not_found、403 comments_disabled/comments_guests_disabled、
  400 校验码、500 article_error),保留 guest 令牌与 flash 机制,成功返回
  {ok,redirect:/article/:slug#comment-N,comment_id}
- api.go:新增 APIErrorf(支持 %d/%s 占位符键如 comments_too_long)
- admin_comment.go:approve/reject/delete 改 JSON(parseUintParam 拒绝非
  数值 id 400),成功带原 ?saved=1&msg= 查询串 redirect
- main.go:PostComment 迁入 /api;评论审核三操作迁入 /api/admin/comments
- article.html:评论表单改 blogAPI 提交,错误内联 commentError div
- comment_list.html:审核操作改 to commentAct() 委托(confirm 在函数内,
  取消不发请求),成功 reload 保持筛选状态
- 测试:security_test env 路由同步 /api;session_upload 评论用例改 JSON
- main_test 冒烟补评论 API 路由断言;go build/vet/test 全绿
This commit is contained in:
2026-08-27 19:36:09 +08:00
parent 923d57475b
commit a0061b14e3
9 files changed
+121 -60

No files matched your search

+15 -6
View File
@@ -132,29 +132,38 @@ func CommentListPage(db *gorm.DB) gin.HandlerFunc {
// CommentApprove 将评论标记为通过。
func CommentApprove(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if id := c.Param("id"); id != "" {
if id := parseUintParam(c, "id"); id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
} else {
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentApproved)
}
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=approved")
APIOK(c, "/admin/comments?status=pending&saved=1&msg=approved", nil)
}
}
// CommentReject 将评论标记为拒绝(前端隐藏,后台列表保留)。
func CommentReject(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if id := c.Param("id"); id != "" {
if id := parseUintParam(c, "id"); id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
} else {
db.Model(&models.Comment{}).Where("id = ?", id).Update("status", models.CommentRejected)
}
c.Redirect(http.StatusFound, "/admin/comments?status=pending&saved=1&msg=rejected")
APIOK(c, "/admin/comments?status=pending&saved=1&msg=rejected", nil)
}
}
// CommentDelete 软删除一条评论。
func CommentDelete(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if id := c.Param("id"); id != "" {
if id := parseUintParam(c, "id"); id == 0 {
APIError(c, http.StatusBadRequest, "api_invalid_request")
return
} else {
db.Where("id = ?", id).Delete(&models.Comment{})
}
c.Redirect(http.StatusFound, "/admin/comments?status=all&saved=1&msg=deleted")
APIOK(c, "/admin/comments?status=all&saved=1&msg=deleted", nil)
}
}
+18
View File
@@ -1,6 +1,7 @@
package handlers
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
@@ -40,6 +41,23 @@ func APIError(c *gin.Context, status int, trKey string) {
})
}
// APIErrorf 同 APIError,但 i18n 文案可按 fmt.Sprintf 格式化
// (针对含 %d/%s 占位符的键,如 comments_too_long)。
func APIErrorf(c *gin.Context, status int, trKey string, args ...interface{}) {
tr := getTr(c)
msg := tr[trKey]
if msg == "" {
msg = tr["api_error"]
} else if len(args) > 0 {
msg = fmt.Sprintf(msg, args...)
}
c.JSON(status, gin.H{
"ok": false,
"code": trKey,
"error": msg,
})
}
// bindJSON 将 JSON 请求体绑定到 v。
// 绑定失败时返回 400 + api_invalid_request,并返回 false。
// 使用前必须保证请求是 JSONContent-Type: application/json)。
+29 -30
View File
@@ -37,14 +37,15 @@ var htmlTagPattern = regexp.MustCompile(`(?is)<[^>]+>`)
// 这些协议在渲染为 innerHTML 时可能执行脚本。
var dangerousSchemePattern = regexp.MustCompile(`(?i)\b(javascript|vbscript|data:text/html)\s*:`)
// commentForm 保存已提交评论表单的解析值,使校验失败后模板能重新填充输入
// commentForm 是 POST /api/article/:slug/comments 的 JSON 请求体
// 校验失败时返回 {ok:false,code,error},不再回填模板。
type commentForm struct {
Name string
Email string
Website string
Content string
IsPrivate bool
ParentID string
Name string `json:"name"`
Email string `json:"email"`
Website string `json:"website"`
Content string `json:"content"`
IsPrivate bool `json:"is_private"`
ParentID string `json:"parent_id"`
}
// sanitizeMarkdown 在存储前从评论正文中剥除 HTML 标签与危险 URL 协议。
@@ -91,65 +92,63 @@ func emailHash(email string) string {
// PostComment 处理在文章上提交新评论(或回复)。
func PostComment(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
slug := c.Param("slug")
var article models.Article
if err := db.Where("slug = ? AND status = ?", slug, models.ArticlePublished).First(&article).Error; err != nil {
data := DefaultData(c)
data["Title"] = tr["article_not_found"]
c.HTML(http.StatusNotFound, "article_not_found", data)
APIError(c, http.StatusNotFound, "article_not_found")
return
}
cfg := models.GetCommentConfig()
if cfg == nil || !cfg.Enabled {
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_disabled"], "")
APIError(c, http.StatusForbidden, "comments_disabled")
return
}
isLoggedIn, _ := c.Get("is_logged_in")
loggedIn, _ := isLoggedIn.(bool)
if !loggedIn && !cfg.AllowGuest {
renderArticleDetail(c, db, &article, commentForm{}, tr["comments_guests_disabled"], "")
APIError(c, http.StatusForbidden, "comments_guests_disabled")
return
}
form := commentForm{
Name: strings.TrimSpace(c.PostForm("name")),
Email: strings.TrimSpace(c.PostForm("email")),
Website: strings.TrimSpace(c.PostForm("website")),
Content: strings.TrimSpace(c.PostForm("content")),
IsPrivate: c.PostForm("is_private") == "1",
ParentID: strings.TrimSpace(c.PostForm("parent_id")),
var form commentForm
if !bindJSON(c, &form) {
return
}
form.Name = strings.TrimSpace(form.Name)
form.Email = strings.TrimSpace(form.Email)
form.Website = strings.TrimSpace(form.Website)
form.Content = strings.TrimSpace(form.Content)
form.ParentID = strings.TrimSpace(form.ParentID)
// --- 校验 ---
if form.Name == "" || len(form.Name) > 64 {
renderArticleDetail(c, db, &article, form, tr["comments_required_name"], "")
APIError(c, http.StatusBadRequest, "comments_required_name")
return
}
if form.Email == "" {
renderArticleDetail(c, db, &article, form, tr["comments_required_email"], "")
APIError(c, http.StatusBadRequest, "comments_required_email")
return
}
if _, err := mail.ParseAddress(form.Email); err != nil {
renderArticleDetail(c, db, &article, form, tr["comments_invalid_email"], "")
APIError(c, http.StatusBadRequest, "comments_invalid_email")
return
}
if form.Website != "" {
u, err := url.Parse(form.Website)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
renderArticleDetail(c, db, &article, form, tr["comments_invalid_url"], "")
APIError(c, http.StatusBadRequest, "comments_invalid_url")
return
}
}
if form.Content == "" {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
APIError(c, http.StatusBadRequest, "comments_required_content")
return
}
if len([]rune(form.Content)) > MaxCommentLength {
renderArticleDetail(c, db, &article, form, fmt.Sprintf(tr["comments_too_long"], MaxCommentLength), "")
APIErrorf(c, http.StatusBadRequest, "comments_too_long", MaxCommentLength)
return
}
@@ -158,12 +157,12 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
if form.ParentID != "" {
pid, err := strconv.ParseUint(form.ParentID, 10, 64)
if err != nil || pid == 0 {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
APIError(c, http.StatusBadRequest, "comments_required_content")
return
}
var parent models.Comment
if err := db.Where("id = ? AND article_id = ? AND status = ?", pid, article.ID, models.CommentApproved).First(&parent).Error; err != nil {
renderArticleDetail(c, db, &article, form, tr["comments_required_content"], "")
APIError(c, http.StatusBadRequest, "comments_required_content")
return
}
id := uint(pid)
@@ -199,7 +198,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
}
if err := db.Create(&comment).Error; err != nil {
renderArticleDetail(c, db, &article, form, tr["article_error"], "")
APIError(c, http.StatusInternalServerError, "article_error")
return
}
@@ -209,7 +208,7 @@ func PostComment(db *gorm.DB) gin.HandlerFunc {
} else {
setCommentFlash(c, getTr(c)["comments_posted"])
}
c.Redirect(http.StatusFound, "/article/"+slug+anchor)
APIOK(c, "/article/"+slug+anchor, gin.H{"comment_id": comment.ID})
}
}
+1 -1
View File
@@ -84,13 +84,13 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
r.GET("/login", LoginPage())
r.GET("/register", RegisterPage(db))
r.GET("/rss", RSSFeed(db))
r.POST("/article/:slug/comments", PostComment(db))
api := r.Group("/api")
{
api.POST("/auth/login", Login(db, limiter))
api.POST("/auth/logout", Logout())
api.POST("/auth/register", Register(db))
api.POST("/article/:slug/comments", PostComment(db))
}
protected := r.Group("/my", middleware.AuthRequired(db))
+9 -8
View File
@@ -15,6 +15,8 @@ import (
"strings"
"testing"
"github.com/gin-gonic/gin"
"go_blog/models"
)
@@ -122,14 +124,13 @@ func TestDisabledUserCommentsRequireApproval(t *testing.T) {
postComment := func() models.Comment {
t.Helper()
form := url.Values{}
form.Set("name", "alice")
form.Set("email", "alice@example.com")
form.Set("content", "comment body")
form.Set("_csrf", token)
w := e.do(http.MethodPost, "/article/alice-post/comments", aliceCookie,
strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if w.Code != http.StatusFound {
w := postJSON(e, http.MethodPost, "/api/article/alice-post/comments", aliceCookie, token,
gin.H{
"name": "alice",
"email": "alice@example.com",
"content": "comment body",
})
if w.Code != http.StatusOK {
t.Fatalf("POST comment: status=%d body=%s", w.Code, w.Body.String())
}
var cm models.Comment
+9 -4
View File
@@ -164,7 +164,6 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
router.GET("/login", handlers.LoginPage())
router.GET("/register", handlers.RegisterPage(db))
router.GET("/article/:slug", handlers.ArticleDetail(db))
router.POST("/article/:slug/comments", handlers.PostComment(db))
// 公开 JSON API。
api := router.Group("/api")
@@ -173,6 +172,7 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
api.POST("/auth/login", handlers.Login(db, loginLimiter))
api.POST("/auth/register", handlers.Register(db))
api.POST("/auth/logout", handlers.Logout())
api.POST("/article/:slug/comments", handlers.PostComment(db))
}
// 受保护的后台路由(仅管理员角色)。
@@ -193,9 +193,14 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
comments.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
comments.GET("", handlers.CommentListPage(db))
comments.POST("/:id/approve", handlers.CommentApprove(db))
comments.POST("/:id/reject", handlers.CommentReject(db))
comments.POST("/:id/delete", handlers.CommentDelete(db))
}
commentsAPI := router.Group("/api/admin/comments")
commentsAPI.Use(middleware.AuthRequired(db), middleware.AdminRequired(db))
{
commentsAPI.POST("/:id/approve", handlers.CommentApprove(db))
commentsAPI.POST("/:id/reject", handlers.CommentReject(db))
commentsAPI.POST("/:id/delete", handlers.CommentDelete(db))
}
// 受保护的后台用户管理路由(仅管理员角色)。
+5
View File
@@ -146,6 +146,11 @@ func TestRegisterRoutesSmoke(t *testing.T) {
"POST /api/auth/login": "",
"POST /api/auth/register": "",
"POST /api/auth/logout": "",
// 评论 API。
"POST /api/article/:slug/comments": "",
"POST /api/admin/comments/:id/approve": "",
"POST /api/admin/comments/:id/reject": "",
"POST /api/admin/comments/:id/delete": "",
// 搬移的附件/头像端点。
"POST /api/admin/articles/attachments": "",
"DELETE /api/admin/articles/attachments/:id": "",
+17 -7
View File
@@ -61,20 +61,17 @@
</div>
<div class="flex justify-end gap-3 mt-3 text-sm">
{{if eq .Status 0}}
<form action="/admin/comments/{{.ID}}/approve" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="comment-act inline" data-id="{{.ID}}" data-act="approve" onsubmit="return commentAct(this)">
<button type="submit" class="text-green-600 hover:text-green-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_approve"}}</button>
</form>
{{end}}
{{if ne .Status 2}}
<form action="/admin/comments/{{.ID}}/reject" method="post" class="inline">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="comment-act inline" data-id="{{.ID}}" data-act="reject" onsubmit="return commentAct(this)">
<button type="submit" class="text-orange-600 hover:text-orange-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_reject"}}</button>
</form>
{{end}}
<form action="/admin/comments/{{.ID}}/delete" method="post" class="inline"
onsubmit="return confirm('{{index $.Tr "comment_delete_confirm"}}');">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<form class="comment-act inline" data-id="{{.ID}}" data-act="delete"
data-confirm="{{index $.Tr "comment_delete_confirm"}}" onsubmit="return commentAct(this)">
<button type="submit" class="text-red-600 hover:text-red-800 font-medium cursor-pointer bg-transparent border-none">{{index $.Tr "comment_delete"}}</button>
</form>
</div>
@@ -98,6 +95,19 @@
}
});
})();
// 评论审核操作(approve/reject/delete)走 JSON API;成功 reload 保持筛选状态。
window.commentAct = function (form) {
var act = form.getAttribute('data-act');
var id = form.getAttribute('data-id');
var confirmText = form.getAttribute('data-confirm');
if (confirmText && !confirm(confirmText)) return false;
blogAPI('POST', '/api/admin/comments/' + id + '/' + act).then(function (r) {
if (r.ok) { window.location.reload(); }
else { alert(r.error || 'Failed'); }
});
return false;
};
</script>
{{template "footer" .}}
{{end}}
+18 -4
View File
@@ -45,9 +45,7 @@
<div id="articleBody" class="prose max-w-none text-gray-800 md-body"></div>
</article>
{{if .CommentError}}
<div class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{{.CommentError}}</div>
{{end}}
<div id="commentError" class="max-w-3xl mx-auto mt-8 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg {{if not .CommentError}}hidden{{end}}">{{.CommentError}}</div>
{{if .CommentConfig}}
{{if .CommentConfig.Enabled}}
@@ -74,7 +72,7 @@
<span id="replyTarget"></span>
<button type="button" id="cancelReply" class="text-blue-600 hover:text-blue-800 font-medium bg-transparent border-none cursor-pointer">{{index .Tr "comments_cancel_reply"}}</button>
</div>
<form id="commentForm" action="/article/{{.Article.Slug}}/comments" method="post">
<form id="commentForm" action="/api/article/{{.Article.Slug}}/comments" method="post">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<input type="hidden" name="parent_id" id="parent_id" value="{{.CommentForm.ParentID}}">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
@@ -263,6 +261,22 @@
document.getElementById('comments').insertBefore(formWrap, document.getElementById('commentList').nextSibling);
});
})();
// Comment submission: JSON API + client-side error display.
(function () {
var form = document.getElementById('commentForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
if (btn) btn.disabled = true;
blogAPI('POST', form.action, blogForm(form, btn)).then(function (r) {
if (btn) btn.disabled = false;
if (r.ok) { window.location.href = r.redirect || form.action; }
else { blogShowError('commentError', r.error || 'Failed to post comment.'); }
});
});
})();
</script>
{{template "footer" .}}