feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View 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)
}