// Package markdown 提供帖子正文隐藏块(:::hide)的解析、校验与脱敏。 package markdown import ( "errors" "fmt" "strconv" "strings" ) const ( HideKindLogin = "login" HideKindReply = "reply" HideKindPoints = "points" ) // HideBlock 正文中的一段隐藏内容 type HideBlock struct { Kind string // login | reply | points Points int // 仅 points 有效 Body string // 块内 Markdown(不含开闭标记行) Start int // 开标记行在 lines 中的下标 End int // 闭标记行在 lines 中的下标(含) Locked bool // 开标记是否已带 locked(脱敏输出) } // DerivedAccess 由正文隐藏块派生的帖级可见性 type DerivedAccess struct { Access string // public | login | reply | points | mixed Points int // 所有积分块价格之和 } // ViewerCaps 读者对隐藏块的能力(由 service 填充) type ViewerCaps struct { Bypass bool // 作者 / 版主 LoggedIn bool HasReplied bool PointsPaid bool // 已支付本帖积分解锁 } var ( ErrHideNested = errors.New("隐藏块不可嵌套") ErrHideUnclosed = errors.New("隐藏块未正确闭合") ErrHideInvalid = errors.New("隐藏块语法无效") ErrHidePointsNeed = errors.New("积分可见块须指定 1–100000 的积分") ) // ParseHideBlocks 扫描正文中的 :::hide 块(忽略代码围栏内伪语法)。 // 校验失败返回 error(用于 Create/Update)。 func ParseHideBlocks(content string) ([]HideBlock, error) { lines := splitLines(content) var blocks []HideBlock inCode := false i := 0 for i < len(lines) { trimmed := strings.TrimSpace(lines[i]) if strings.HasPrefix(trimmed, "```") { inCode = !inCode i++ continue } if inCode { i++ continue } if kind, pts, locked, ok := parseOpenMarker(trimmed); ok { j := i + 1 bodyLines := make([]string, 0) foundClose := false innerCode := false for j < len(lines) { inner := strings.TrimSpace(lines[j]) if strings.HasPrefix(inner, "```") { innerCode = !innerCode bodyLines = append(bodyLines, lines[j]) j++ continue } if innerCode { bodyLines = append(bodyLines, lines[j]) j++ continue } if _, _, _, isOpen := parseOpenMarker(inner); isOpen { return nil, ErrHideNested } if isCloseMarker(inner) { foundClose = true break } bodyLines = append(bodyLines, lines[j]) j++ } if !foundClose { return nil, ErrHideUnclosed } if kind == HideKindPoints { if pts < 1 || pts > 100000 { return nil, ErrHidePointsNeed } } blocks = append(blocks, HideBlock{ Kind: kind, Points: pts, Body: strings.Join(bodyLines, "\n"), Start: i, End: j, Locked: locked, }) i = j + 1 continue } if isCloseMarker(trimmed) { return nil, ErrHideInvalid } i++ } return blocks, nil } // DeriveAccess 由隐藏块列表派生帖级 content_access / access_points func DeriveAccess(blocks []HideBlock) DerivedAccess { if len(blocks) == 0 { return DerivedAccess{Access: "public", Points: 0} } kinds := map[string]struct{}{} totalPts := 0 for _, b := range blocks { kinds[b.Kind] = struct{}{} if b.Kind == HideKindPoints { totalPts += b.Points } } if len(kinds) > 1 { return DerivedAccess{Access: "mixed", Points: totalPts} } for k := range kinds { return DerivedAccess{Access: k, Points: totalPts} } return DerivedAccess{Access: "public", Points: 0} } // DeriveAccessFromContent 解析并派生;校验失败返回 error func DeriveAccessFromContent(content string) (DerivedAccess, error) { blocks, err := ParseHideBlocks(content) if err != nil { return DerivedAccess{}, err } return DeriveAccess(blocks), nil } // SanitizeForViewer 按读者能力脱敏:未满足条件的块清空正文并加 locked。 // fullyLocked 表示读者当前看不到任何可见正文(整篇都在锁块里或公开区为空)。 func SanitizeForViewer(content string, caps ViewerCaps) (sanitized string, fullyLocked bool) { blocks, err := ParseHideBlocks(content) if err != nil { return "", true } if len(blocks) == 0 { return content, strings.TrimSpace(content) == "" } if caps.Bypass { return content, false } lines := splitLines(content) var out []string cursor := 0 anyVisible := false for _, b := range blocks { for i := cursor; i < b.Start; i++ { out = append(out, lines[i]) if strings.TrimSpace(lines[i]) != "" { anyVisible = true } } if blockUnlocked(b, caps) { out = append(out, openMarkerLine(b.Kind, b.Points, false)) if b.Body != "" { out = append(out, splitLines(b.Body)...) if strings.TrimSpace(b.Body) != "" { anyVisible = true } } out = append(out, ":::") } else { out = append(out, openMarkerLine(b.Kind, b.Points, true)) out = append(out, ":::") } cursor = b.End + 1 } for i := cursor; i < len(lines); i++ { out = append(out, lines[i]) if strings.TrimSpace(lines[i]) != "" { anyVisible = true } } return strings.Join(out, "\n"), !anyVisible } // WrapContentAsHide 将整篇正文包进单一隐藏块(旧帖迁移用) func WrapContentAsHide(kind string, points int, content string) string { body := strings.TrimRight(content, "\n") open := openMarkerLine(kind, points, false) if body == "" { return open + "\n:::\n" } return open + "\n" + body + "\n:::\n" } // HasHideBlocks 快速判断正文是否已含隐藏块(迁移幂等) func HasHideBlocks(content string) bool { blocks, err := ParseHideBlocks(content) if err != nil { return strings.Contains(content, ":::hide") } return len(blocks) > 0 } func blockUnlocked(b HideBlock, caps ViewerCaps) bool { switch b.Kind { case HideKindLogin: return caps.LoggedIn case HideKindReply: return caps.HasReplied case HideKindPoints: return caps.PointsPaid default: return true } } func openMarkerLine(kind string, points int, locked bool) string { var b strings.Builder b.WriteString(":::hide ") b.WriteString(kind) if kind == HideKindPoints && points > 0 { b.WriteByte(' ') b.WriteString(strconv.Itoa(points)) } if locked { b.WriteString(" locked") } return b.String() } func parseOpenMarker(trimmed string) (kind string, points int, locked bool, ok bool) { if !strings.HasPrefix(trimmed, ":::hide") { return "", 0, false, false } rest := strings.TrimSpace(trimmed[len(":::hide"):]) if rest == "" { return "", 0, false, false } parts := strings.Fields(rest) if len(parts) == 0 { return "", 0, false, false } kind = parts[0] switch kind { case HideKindLogin, HideKindReply, HideKindPoints: default: return "", 0, false, false } idx := 1 if kind == HideKindPoints { if idx >= len(parts) { return "", 0, false, false } n, err := strconv.Atoi(parts[idx]) if err != nil { return "", 0, false, false } points = n idx++ } for ; idx < len(parts); idx++ { if parts[idx] == "locked" { locked = true } else { return "", 0, false, false } } return kind, points, locked, true } func isCloseMarker(trimmed string) bool { return trimmed == ":::" } func splitLines(s string) []string { if s == "" { return []string{} } s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") return strings.Split(s, "\n") } // ValidateHideContent 校验正文隐藏块;失败返回用户可读错误 func ValidateHideContent(content string) error { _, err := ParseHideBlocks(content) if err == nil { return nil } switch { case errors.Is(err, ErrHideNested): return fmt.Errorf("隐藏块不可嵌套") case errors.Is(err, ErrHideUnclosed): return fmt.Errorf("隐藏块未正确闭合,请检查 ::: 标记") case errors.Is(err, ErrHidePointsNeed): return fmt.Errorf("积分可见块须指定 1–100000 的积分") case errors.Is(err, ErrHideInvalid): return fmt.Errorf("隐藏块语法无效") default: return err } }