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