feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
422
backend/service/library_section.go
Normal file
422
backend/service/library_section.go
Normal file
@@ -0,0 +1,422 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 章节约束:两级树(章 → 节),正文为 markdown
|
||||
const (
|
||||
MaxSectionsPerDoc = 200 // 单条目章+节总数上限
|
||||
MaxSectionContent = 100_000 // 单章节正文字符上限
|
||||
MaxImportBytes = 5 << 20 // 单个 md 文件字节上限(5MB)
|
||||
MaxImportFiles = 50 // 单次批量导入文件数上限
|
||||
MaxImportTotalBytes = 20 << 20 // 批量导入总字节上限(20MB)
|
||||
)
|
||||
|
||||
var ErrSectionNotFound = errors.New("章节不存在")
|
||||
|
||||
// SectionInput 创建/更新章节入参(更新仅改标题与正文,父子关系由创建时决定、顺序用 move 调整)
|
||||
type SectionInput struct {
|
||||
ParentID *uint `json:"parent_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
func (in *SectionInput) normalize() error {
|
||||
if in.ParentID != nil && *in.ParentID == 0 {
|
||||
in.ParentID = nil
|
||||
}
|
||||
in.Title = strings.TrimSpace(in.Title)
|
||||
if in.Title == "" {
|
||||
return errors.New("章节标题不能为空")
|
||||
}
|
||||
if utf8.RuneCountInString(in.Title) > 200 {
|
||||
return errors.New("章节标题不能超过 200 字")
|
||||
}
|
||||
if utf8.RuneCountInString(in.Content) > MaxSectionContent {
|
||||
return errors.New("章节正文不能超过 100000 字")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) section(id uint) (*model.LibrarySection, error) {
|
||||
var sec model.LibrarySection
|
||||
if err := s.db.First(&sec, id).Error; err != nil {
|
||||
return nil, ErrSectionNotFound
|
||||
}
|
||||
return &sec, nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) sectionCount(docID uint) (int64, error) {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID).Count(&n).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// sectionMaxSort 同级兄弟的当前最大 sort_order(无兄弟时 -1)
|
||||
func (s *LibraryService) sectionMaxSort(docID uint, parentID *uint) (int, error) {
|
||||
q := s.db.Model(&model.LibrarySection{}).Where("doc_id = ?", docID)
|
||||
if parentID == nil {
|
||||
q = q.Where("parent_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("parent_id = ?", *parentID)
|
||||
}
|
||||
var maxSort int
|
||||
if err := q.Select("COALESCE(MAX(sort_order), -1)").Scan(&maxSort).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxSort, nil
|
||||
}
|
||||
|
||||
// ListSections 条目下全部章节(平铺,章在前节按组内序)
|
||||
func (s *LibraryService) ListSections(docID uint) ([]model.LibrarySection, error) {
|
||||
var secs []model.LibrarySection
|
||||
if err := s.db.Where("doc_id = ?", docID).
|
||||
Order("sort_order ASC, id ASC").Find(&secs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return secs, nil
|
||||
}
|
||||
|
||||
// validateParent 两级约束:父必须是同条目下的章(自身不能是节)
|
||||
func (s *LibraryService) validateParent(docID uint, parentID *uint) error {
|
||||
if parentID == nil {
|
||||
return nil
|
||||
}
|
||||
parent, err := s.section(*parentID)
|
||||
if err != nil {
|
||||
return errors.New("父章节不存在")
|
||||
}
|
||||
if parent.DocID != docID {
|
||||
return errors.New("父章节不属于该条目")
|
||||
}
|
||||
if parent.ParentID != nil {
|
||||
return errors.New("目录最多两级:章下不能再建小节")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LibraryService) CreateSection(docID uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc model.LibraryDoc
|
||||
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if err := s.validateParent(docID, in.ParentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count, err := s.sectionCount(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count >= MaxSectionsPerDoc {
|
||||
return nil, errors.New("章节数量超过上限")
|
||||
}
|
||||
maxSort, err := s.sectionMaxSort(docID, in.ParentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID,
|
||||
ParentID: in.ParentID,
|
||||
Title: in.Title,
|
||||
Content: in.Content,
|
||||
SortOrder: maxSort + 1,
|
||||
}
|
||||
if err := s.db.Create(sec).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sec, nil
|
||||
}
|
||||
|
||||
// UpdateSection 更新标题与正文(父章节与顺序不变)
|
||||
func (s *LibraryService) UpdateSection(id uint, in *SectionInput) (*model.LibrarySection, error) {
|
||||
in.ParentID = nil // 忽略传入的 parent:父子关系不在此处变更
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(sec).Updates(map[string]interface{}{
|
||||
"title": in.Title,
|
||||
"content": in.Content,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.section(id)
|
||||
}
|
||||
|
||||
// DeleteSection 删除章节;若为章则级联软删其下小节
|
||||
func (s *LibraryService) DeleteSection(id uint) error {
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if sec.ParentID == nil {
|
||||
if err := tx.Where("parent_id = ?", sec.ID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(sec).Error
|
||||
})
|
||||
}
|
||||
|
||||
// MoveSection 与同父相邻兄弟交换 sort_order(边界为 no-op)
|
||||
func (s *LibraryService) MoveSection(id uint, direction string) error {
|
||||
if direction != "up" && direction != "down" {
|
||||
return errors.New("无效的移动方向")
|
||||
}
|
||||
sec, err := s.section(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := s.db.Where("doc_id = ?", sec.DocID)
|
||||
if sec.ParentID == nil {
|
||||
q = q.Where("parent_id IS NULL")
|
||||
} else {
|
||||
q = q.Where("parent_id = ?", *sec.ParentID)
|
||||
}
|
||||
var sibs []model.LibrarySection
|
||||
if err := q.Order("sort_order ASC, id ASC").Find(&sibs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idx := -1
|
||||
for i := range sibs {
|
||||
if sibs[i].ID == sec.ID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return ErrSectionNotFound
|
||||
}
|
||||
var other *model.LibrarySection
|
||||
if direction == "up" && idx > 0 {
|
||||
other = &sibs[idx-1]
|
||||
}
|
||||
if direction == "down" && idx < len(sibs)-1 {
|
||||
other = &sibs[idx+1]
|
||||
}
|
||||
if other == nil {
|
||||
return nil // 已在边界,无需移动
|
||||
}
|
||||
a, b := sec.SortOrder, other.SortOrder
|
||||
if a == b {
|
||||
b = a + 1 // 相同排序值时保证交换后顺序确实变化
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&model.LibrarySection{}).Where("id = ?", sec.ID).
|
||||
UpdateColumn("sort_order", b).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.LibrarySection{}).Where("id = ?", other.ID).
|
||||
UpdateColumn("sort_order", a).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ---------- md 拆章导入 ----------
|
||||
|
||||
type mdChapterChunk struct {
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
// splitMarkdownChapters 按指定级别标题("# " 或 "## ")切块;
|
||||
// 代码围栏内的 # 行不视为标题;首个标题之前的内容并入第一章正文。
|
||||
func splitMarkdownChapters(content, level string) []mdChapterChunk {
|
||||
prefix := "# "
|
||||
if level == "h2" {
|
||||
prefix = "## "
|
||||
}
|
||||
var chunks []mdChapterChunk
|
||||
var pre strings.Builder
|
||||
bodies := []strings.Builder{}
|
||||
cur := -1
|
||||
inCode := false
|
||||
text := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(t, "```") {
|
||||
inCode = !inCode
|
||||
}
|
||||
if !inCode && strings.HasPrefix(t, prefix) {
|
||||
title := strings.TrimSpace(strings.TrimPrefix(t, prefix))
|
||||
title = strings.TrimSpace(strings.TrimRight(title, "#"))
|
||||
chunks = append(chunks, mdChapterChunk{Title: title})
|
||||
bodies = append(bodies, strings.Builder{})
|
||||
cur = len(chunks) - 1
|
||||
continue
|
||||
}
|
||||
if cur < 0 {
|
||||
pre.WriteString(line)
|
||||
pre.WriteString("\n")
|
||||
} else {
|
||||
bodies[cur].WriteString(line)
|
||||
bodies[cur].WriteString("\n")
|
||||
}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]mdChapterChunk, len(chunks))
|
||||
for i := range chunks {
|
||||
body := bodies[i].String()
|
||||
if i == 0 && pre.Len() > 0 {
|
||||
body = pre.String() + body
|
||||
}
|
||||
out[i] = mdChapterChunk{Title: chunks[i].Title, Body: strings.TrimSpace(body)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// truncateRunes 按 rune 截断
|
||||
func truncateRunes(s string, n int) string {
|
||||
if utf8.RuneCountInString(s) <= n {
|
||||
return s
|
||||
}
|
||||
return string([]rune(s)[:n])
|
||||
}
|
||||
|
||||
// ImportFileInput 批量导入的单个文件
|
||||
type ImportFileInput struct {
|
||||
Filename string `json:"filename"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// chunksFromFile 把单个文件拆成章块:校验单文件大小,标题回退取文件名;
|
||||
// splitLevel 为 none 或未命中标题时整篇一章。
|
||||
func chunksFromFile(filename, content, splitLevel string) (string, []mdChapterChunk, error) {
|
||||
if len(content) > MaxImportBytes {
|
||||
return "", nil, errors.New("文件 " + sanitizeFilename(filename) + " 过大:单个文件不能超过 5MB")
|
||||
}
|
||||
base := sanitizeFilename(filename)
|
||||
if i := strings.LastIndex(base, "."); i > 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
base = strings.TrimSpace(base)
|
||||
if base == "" {
|
||||
base = "未命名章节"
|
||||
}
|
||||
if utf8.RuneCountInString(base) > 200 {
|
||||
base = truncateRunes(base, 200)
|
||||
}
|
||||
|
||||
var chunks []mdChapterChunk
|
||||
if splitLevel == "none" {
|
||||
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||
} else {
|
||||
chunks = splitMarkdownChapters(content, splitLevel)
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
chunks = []mdChapterChunk{{Title: base, Body: strings.TrimSpace(content)}}
|
||||
}
|
||||
return base, chunks, nil
|
||||
}
|
||||
|
||||
// ImportMarkdownBatch 批量导入:多个 md 文件按传入顺序依次拆成顶级章(不生成节)。
|
||||
// splitLevel 对每个文件生效;无命中标题的文件回退为单章(标题取文件名)。
|
||||
// 任一文件失败整体回滚。单文件导入即长度为 1 的批量。
|
||||
func (s *LibraryService) ImportMarkdownBatch(docID uint, files []ImportFileInput, splitLevel string) ([]model.LibrarySection, error) {
|
||||
var doc model.LibraryDoc
|
||||
if err := s.db.First(&doc, docID).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
if splitLevel != "h1" && splitLevel != "h2" && splitLevel != "none" {
|
||||
return nil, errors.New("无效的拆分方式")
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return nil, errors.New("没有可导入的文件")
|
||||
}
|
||||
if len(files) > MaxImportFiles {
|
||||
return nil, errors.New("单次最多导入 " + strconv.Itoa(MaxImportFiles) + " 个文件")
|
||||
}
|
||||
total := 0
|
||||
for _, f := range files {
|
||||
total += len(f.Content)
|
||||
}
|
||||
if total > MaxImportTotalBytes {
|
||||
return nil, errors.New("导入内容总量过大:不能超过 20MB")
|
||||
}
|
||||
|
||||
// 逐文件拆块(保留文件边界,便于错误提示定位到文件)
|
||||
type mdFilePlan struct {
|
||||
Base string
|
||||
Chunks []mdChapterChunk
|
||||
}
|
||||
plan := make([]mdFilePlan, 0, len(files))
|
||||
totalChunks := 0
|
||||
for _, f := range files {
|
||||
base, chunks, err := chunksFromFile(f.Filename, f.Content, splitLevel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan = append(plan, mdFilePlan{Base: base, Chunks: chunks})
|
||||
totalChunks += len(chunks)
|
||||
}
|
||||
|
||||
count, err := s.sectionCount(docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count+int64(totalChunks) > MaxSectionsPerDoc {
|
||||
return nil, errors.New("导入后的章节数超过上限(现有 + 导入 ≤ 200)")
|
||||
}
|
||||
maxSort, err := s.sectionMaxSort(docID, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
created := make([]model.LibrarySection, 0, totalChunks)
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
offset := 0
|
||||
for _, fp := range plan {
|
||||
for i, ch := range fp.Chunks {
|
||||
title := strings.TrimSpace(ch.Title)
|
||||
if title == "" {
|
||||
title = fp.Base + "(续)"
|
||||
}
|
||||
if utf8.RuneCountInString(title) > 200 {
|
||||
title = truncateRunes(title, 200)
|
||||
}
|
||||
if utf8.RuneCountInString(ch.Body) > MaxSectionContent {
|
||||
return errors.New("「" + fp.Base + "」第 " + strconv.Itoa(i+1) + " 章正文超过 100000 字,请先拆分后再导入")
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID,
|
||||
Title: title,
|
||||
Content: ch.Body,
|
||||
SortOrder: maxSort + 1 + offset + i,
|
||||
}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = append(created, *sec)
|
||||
}
|
||||
offset += len(fp.Chunks)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// ImportMarkdown 单文件导入(批量为长度 1 的特例,保留旧端点兼容)
|
||||
func (s *LibraryService) ImportMarkdown(docID uint, filename, content, splitLevel string) ([]model.LibrarySection, error) {
|
||||
return s.ImportMarkdownBatch(docID, []ImportFileInput{{Filename: filename, Content: content}}, splitLevel)
|
||||
}
|
||||
Reference in New Issue
Block a user