feat(compose): 正文内嵌隐藏块并升级 Markdown 编辑器

去掉整帖可见性表单,改为 :::hide 按块脱敏;工具栏补齐代码块、链接/图片对话框与粘贴拖拽上传。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:58:20 +08:00
parent d1155cbd64
commit 01e2fd05ca
19 changed files with 1867 additions and 471 deletions

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
@@ -402,7 +403,7 @@ type PostDetail struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreatePostInput 发帖入参
// CreatePostInput 发帖入参(content_access / access_points 由正文 :::hide 派生)
type CreatePostInput struct {
UserID uint
BoardID uint
@@ -410,8 +411,6 @@ type CreatePostInput struct {
Content string
Tags string
PostType string
ContentAccess string
AccessPoints int
TypeMeta string
Status string
AttachmentIDs []uint
@@ -422,8 +421,6 @@ type UpdatePostInput struct {
Title string
Content string
Tags string
ContentAccess *string
AccessPoints *int
TypeMeta *string
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
}
@@ -464,12 +461,10 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
post.ViewCount++
detail := buildPostDetail(&post)
locked, hint := s.evalContentAccess(&post, viewerID, loadActor)
detail.ContentLocked = locked
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor)
detail.Content = sanitized
detail.ContentLocked = fullyLocked
detail.AccessHint = hint
if locked {
detail.Content = ""
}
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
detail.Attachments = atts
@@ -521,56 +516,81 @@ func buildPostDetail(post *model.Post) *PostDetail {
}
}
func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) {
access := model.NormalizeContentAccess(post.ContentAccess)
if access == model.ContentAccessPublic {
return false, ""
// sanitizePostContent 按读者能力对正文 :::hide 块脱敏;fullyLocked 表示无可见正文。
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor) (content string, fullyLocked bool, hint string) {
caps := s.buildHideViewerCaps(post, viewerID, loadActor)
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)
if !fully {
return sanitized, false, ""
}
// 作者与版主始终可见
access := model.NormalizeContentAccess(post.ContentAccess)
switch access {
case model.ContentAccessPoints:
return sanitized, true, "支付积分后可见隐藏内容"
case model.ContentAccessMixed:
if post.AccessPoints > 0 {
return sanitized, true, "满足条件后可见隐藏内容"
}
return sanitized, true, "满足条件后可见隐藏内容"
case model.ContentAccessReply:
return sanitized, true, "回复本帖后可见隐藏内容"
case model.ContentAccessLogin:
return sanitized, true, "登录后可见隐藏内容"
default:
return sanitized, true, "正文暂不可见"
}
}
func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor) markdown.ViewerCaps {
caps := markdown.ViewerCaps{}
if viewerID > 0 && post.UserID == viewerID {
return false, ""
caps.Bypass = true
caps.LoggedIn = true
return caps
}
if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
return false, ""
caps.Bypass = true
caps.LoggedIn = true
return caps
}
if viewerID == 0 {
return caps
}
caps.LoggedIn = true
needReply := false
needPoints := false
blocks, err := markdown.ParseHideBlocks(post.Content)
if err == nil {
for _, b := range blocks {
if b.Kind == markdown.HideKindReply {
needReply = true
}
if b.Kind == markdown.HideKindPoints {
needPoints = true
}
}
} else {
access := model.NormalizeContentAccess(post.ContentAccess)
needReply = access == model.ContentAccessReply || access == model.ContentAccessMixed
needPoints = post.AccessPoints > 0 || access == model.ContentAccessPoints || access == model.ContentAccessMixed
}
switch access {
case model.ContentAccessLogin:
if viewerID == 0 {
return true, "登录后可见全文"
}
return false, ""
case model.ContentAccessReply:
if viewerID == 0 {
return true, "回复本帖后可见全文"
}
if needReply {
var n int64
s.db.Model(&model.Comment{}).
Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL",
post.ID, viewerID, model.ContentStatusPublished).
Count(&n)
if n == 0 {
return true, "回复本帖后可见全文"
}
return false, ""
case model.ContentAccessPoints:
need := post.AccessPoints
if need <= 0 {
need = 1
}
if viewerID == 0 {
return true, "支付积分后可见全文"
}
caps.HasReplied = n > 0
}
if needPoints {
var n int64
s.db.Model(&model.PostContentUnlock{}).
Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
if n > 0 {
return false, ""
}
return true, "支付积分后可见全文"
default:
return false, ""
caps.PointsPaid = n > 0
}
return caps
}
func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) {
@@ -605,21 +625,25 @@ func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]Pos
return out, nil
}
// UnlockContent 积分解锁正文
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
var post model.Post
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound
}
if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints {
derived, derr := markdown.DeriveAccessFromContent(post.Content)
if derr != nil {
return nil, derr
}
need := derived.Points
if need <= 0 {
need = post.AccessPoints
}
if need <= 0 {
return nil, errors.New("本文无需积分解锁")
}
if post.UserID == userID {
return buildPostDetail(&post), nil
}
need := post.AccessPoints
if need <= 0 {
need = 1
return s.detailAfterInteract(&post, userID)
}
err := s.db.Transaction(func(tx *gorm.DB) error {
var n int64
@@ -645,11 +669,7 @@ func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
if err != nil {
return nil, err
}
detail := buildPostDetail(&post)
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
detail.Attachments = atts
s.fillInteractState(detail, &post, userID, nil)
return detail, nil
return s.detailAfterInteract(&post, userID)
}
// Create 创建帖子。status 由 handler 按角色计算
@@ -673,18 +693,15 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
if !model.ValidPostType(postType) {
return nil, errors.New("无效的帖子类型")
}
access := model.NormalizeContentAccess(in.ContentAccess)
accessPts := in.AccessPoints
if access == model.ContentAccessPoints {
if accessPts <= 0 {
return nil, errors.New("请设置解锁所需积分")
}
if accessPts > 100000 {
return nil, errors.New("解锁积分过高")
}
} else {
accessPts = 0
if err := markdown.ValidateHideContent(content); err != nil {
return nil, err
}
derived, err := markdown.DeriveAccessFromContent(content)
if err != nil {
return nil, err
}
access := model.NormalizeContentAccess(derived.Access)
accessPts := derived.Points
typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta)
if err != nil {
return nil, err
@@ -840,31 +857,19 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
if c == "" {
return nil, errors.New("内容不能为空")
}
if err := markdown.ValidateHideContent(c); err != nil {
return nil, err
}
derived, err := markdown.DeriveAccessFromContent(c)
if err != nil {
return nil, err
}
updates["content"] = c
updates["content_access"] = model.NormalizeContentAccess(derived.Access)
updates["access_points"] = derived.Points
}
updates["tags"] = in.Tags
if in.ContentAccess != nil {
access := model.NormalizeContentAccess(*in.ContentAccess)
updates["content_access"] = access
if access == model.ContentAccessPoints {
pts := post.AccessPoints
if in.AccessPoints != nil {
pts = *in.AccessPoints
}
if pts <= 0 {
return nil, errors.New("请设置解锁所需积分")
}
updates["access_points"] = pts
} else {
updates["access_points"] = 0
}
} else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints {
if *in.AccessPoints <= 0 {
return nil, errors.New("请设置解锁所需积分")
}
updates["access_points"] = *in.AccessPoints
}
if in.TypeMeta != nil {
pt := model.NormalizePostType(post.PostType)
meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta)