增加回复可见功能

This commit is contained in:
2026-08-05 12:44:00 +08:00
parent 5b2d9a4ea4
commit cc51c49272
20 changed files with 844 additions and 73 deletions

View File

@@ -19,6 +19,20 @@ func NewCommentService(filter *SensitiveFilter, settings *ForumSettingsService)
return &CommentService{filter: filter, settings: settings}
}
// HasUserReplied 用户是否已在该帖发表过有效评论(已发布或审核中,不含被拒)
func (s *CommentService) HasUserReplied(postID, userID uint) bool {
if postID == 0 || userID == 0 {
return false
}
var count int64
err := model.DB.Model(&model.Comment{}).
Where("post_id = ? AND user_id = ? AND status IN ?", postID, userID,
[]string{model.ContentStatusPublished, model.ContentStatusPending}).
Limit(1).
Count(&count).Error
return err == nil && count > 0
}
type CommentCreateInput struct {
UserID uint
PostID uint

View File

@@ -9,6 +9,7 @@ import (
var (
membersOnlyBlockRe = regexp.MustCompile(`(?is)<members-only\b[^>]*>([\s\S]*?)</members-only>`)
replyOnlyBlockRe = regexp.MustCompile(`(?is)<reply-only\b[^>]*>([\s\S]*?)</reply-only>`)
// style/script 内文本不能进搜索/摘要,否则会出现 "* {color:red}" 之类噪声
styleOrScriptRe = regexp.MustCompile(`(?is)<(style|script)\b[^>]*>[\s\S]*?</(style|script)>`)
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
@@ -16,21 +17,39 @@ var (
// RedactMembersOnlyHTML 未登录时移除会员专属区块内的正文,保留长度提示供前端展示
func RedactMembersOnlyHTML(html string) string {
return redactGatedBlocks(html, membersOnlyBlockRe, "members-only")
}
// RedactReplyOnlyHTML 未回复时移除「回复可见」区块内的正文,保留长度提示供前端展示
func RedactReplyOnlyHTML(html string) string {
return redactGatedBlocks(html, replyOnlyBlockRe, "reply-only")
}
// RedactGatedPostHTML 搜索/SEO 等场景:同时遮盖登录可见与回复可见正文
func RedactGatedPostHTML(html string) string {
return RedactReplyOnlyHTML(RedactMembersOnlyHTML(html))
}
func redactGatedBlocks(html string, re *regexp.Regexp, tag string) string {
if html == "" {
return html
}
return membersOnlyBlockRe.ReplaceAllStringFunc(html, func(full string) string {
m := membersOnlyBlockRe.FindStringSubmatch(full)
return re.ReplaceAllStringFunc(html, func(full string) string {
m := re.FindStringSubmatch(full)
inner := ""
if len(m) > 1 {
inner = m[1]
}
length := membersContentLength(inner)
return `<members-only data-locked="true" data-length="` + strconv.Itoa(length) + `"></members-only>`
length := gatedContentLength(inner)
gate := "login"
if tag == "reply-only" {
gate = "reply"
}
return `<` + tag + ` data-gate="` + gate + `" data-locked="true" data-length="` + strconv.Itoa(length) + `"></` + tag + `>`
})
}
func membersContentLength(html string) int {
func gatedContentLength(html string) int {
text := strings.TrimSpace(htmlTagRe.ReplaceAllString(html, ""))
if text == "" {
return 0

28
service/content_test.go Normal file
View File

@@ -0,0 +1,28 @@
package service
import (
"strings"
"testing"
)
func TestRedactReplyOnlyHTML(t *testing.T) {
in := `<p>公开</p><reply-only><p>秘密答案</p></reply-only>`
out := RedactReplyOnlyHTML(in)
if strings.Contains(out, "秘密答案") {
t.Fatalf("不应保留回复可见正文,得到: %q", out)
}
if !strings.Contains(out, `data-locked="true"`) || !strings.Contains(out, "reply-only") {
t.Fatalf("应保留锁定壳,得到: %q", out)
}
if !strings.Contains(out, "公开") {
t.Fatalf("不应误删公开段落,得到: %q", out)
}
}
func TestRedactGatedPostHTML(t *testing.T) {
in := `<members-only><p>登录密</p></members-only><reply-only><p>回复密</p></reply-only>`
out := RedactGatedPostHTML(in)
if strings.Contains(out, "登录密") || strings.Contains(out, "回复密") {
t.Fatalf("门控正文应被遮盖,得到: %q", out)
}
}

View File

@@ -359,7 +359,7 @@ func (s *PostService) Create(userID, boardID uint, title, content, tags, postTyp
UserID: userID,
Title: title,
Content: content,
ContentPlain: StripHTMLForSearch(content),
ContentPlain: StripHTMLForSearch(RedactGatedPostHTML(content)),
Tags: tags,
PostType: postType,
QuestionResolved: false,
@@ -420,7 +420,7 @@ func (s *PostService) Update(userID, postID uint, isAdmin bool, title, content,
"board_id": nextBoardID,
"title": title,
"content": content,
"content_plain": StripHTMLForSearch(content),
"content_plain": StripHTMLForSearch(RedactGatedPostHTML(content)),
"tags": tags,
"post_type": nextType,
"question_resolved": nextResolved,

View File

@@ -17,16 +17,16 @@ func postContentHTMLPolicy() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
// TipTap / Markdown 转换会用到的结构
p.AllowElements("div", "span", "u", "s", "center", "members-only")
p.AllowElements("div", "span", "u", "s", "center", "members-only", "reply-only")
p.AllowAttrs("class").OnElements(
"p", "div", "span", "pre", "code", "img", "a",
"h1", "h2", "h3", "h4", "h5", "h6",
"blockquote", "ul", "ol", "li", "table", "thead", "tbody", "tr", "th", "td",
"members-only",
"members-only", "reply-only",
)
p.AllowAttrs("colspan", "rowspan").OnElements("th", "td")
p.AllowAttrs(
"data-locked", "data-length",
"data-locked", "data-length", "data-gate",
"data-code-copy", "data-code-fold", "data-lang", "data-full",
"data-code-style", "data-line-numbers", "data-collapsed",
"data-line-count", "data-lineno-digits",

View File

@@ -34,11 +34,12 @@ func TestSanitizePostHTML_StripsInlineStyleAndScript(t *testing.T) {
}
func TestSanitizePostHTML_KeepsMembersOnlyAndImageGroup(t *testing.T) {
in := `<members-only data-locked="false"><p>密</p></members-only>` +
in := `<members-only data-gate="login"><p>密</p></members-only>` +
`<reply-only data-gate="reply"><p>回复可见</p></reply-only>` +
`<div data-image-group data-layout="cols-2" class="image-group"><img src="/uploads/posts/a.jpg" alt="x"></div>` +
`<p data-clear-float class="article-clear-float">清浮动</p>`
out := SanitizePostHTML(in)
for _, want := range []string{"members-only", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
for _, want := range []string{"members-only", "reply-only", "data-gate", "回复可见", "data-image-group", "data-layout", "data-clear-float", "清浮动"} {
if !strings.Contains(out, want) {
t.Fatalf("缺少 %q得到: %q", want, out)
}