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:
9 files changed
+121
-60
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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。
|
||||
// 使用前必须保证请求是 JSON(Content-Type: application/json)。
|
||||
|
||||
+29
-30
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 受保护的后台用户管理路由(仅管理员角色)。
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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}}
|
||||
@@ -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" .}}
|
||||
|
||||
Reference in New Issue
Block a user