package service // 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面), // 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。 // // 单本 ZIP 布局: // // book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录) // cover. 本地封面(外链封面不内嵌,仅保留 cover_url) // files/ 附件原文件 // // 全库 ZIP 布局: // // library.json 索引(每本书的目录位置) // docs//book.json 各书清单 // docs//cover. // docs//files/ import ( "archive/zip" "bytes" "encoding/json" "errors" "io" "os" "path/filepath" "strconv" "strings" "time" "github.com/freefire/jiang13-bbs/model" ) const ( libraryExportFormat = "jiang13-library" // 全库包 libraryExportBookFormat = "jiang13-library-book" // 单本书包 libraryExportVersion = 1 ) // 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致) var libraryExportCoverExts = map[string]struct{}{ "jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {}, } // libraryExportFile 附件清单条目 type libraryExportFile struct { Name string `json:"name"` // 原始文件名 Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空) Ext string `json:"ext"` MIME string `json:"mime"` Size int `json:"size"` DownloadCount int `json:"download_count"` SortOrder int `json:"sort_order"` Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失 } // libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key) type libraryExportSection struct { Key string `json:"key"` ParentKey string `json:"parent_key,omitempty"` Title string `json:"title"` Content string `json:"content"` SortOrder int `json:"sort_order"` } // libraryExportBook 单本书的完整清单 type libraryExportBook struct { ID uint `json:"id"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Slug string `json:"slug"` Title string `json:"title"` Author string `json:"author"` Description string `json:"description"` CoverURL string `json:"cover_url"` CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径 CoverWidth int `json:"cover_width"` CoverHeight int `json:"cover_height"` Published bool `json:"published"` SortOrder int `json:"sort_order"` EntriesAuto bool `json:"entries_auto"` Files []libraryExportFile `json:"files"` Sections []libraryExportSection `json:"sections"` } // libraryExportIndexEntry 全库索引中的单本概要 type libraryExportIndexEntry struct { Slug string `json:"slug"` Title string `json:"title"` Dir string `json:"dir"` // 相对 ZIP 根的目录 FileCount int `json:"file_count"` SectionCount int `json:"section_count"` } // libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引 type libraryExportManifest struct { Format string `json:"format"` FormatVersion int `json:"format_version"` ExportedAt string `json:"exported_at"` Doc *libraryExportBook `json:"doc,omitempty"` Docs []libraryExportIndexEntry `json:"docs,omitempty"` } // preparedFile 已打开、待流式拷入 ZIP 的附件 type preparedFile struct { zipPath string reader *os.File } // preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄) type preparedBook struct { meta libraryExportBook dir string // ZIP 内目录前缀(单本包为空串 = 根目录) coverPath string // ZIP 内封面路径(空 = 不内嵌) coverReader *os.File files []preparedFile // 仅磁盘存在的附件 } func (b *preparedBook) closeReaders() { if b.coverReader != nil { _ = b.coverReader.Close() } for i := range b.files { _ = b.files[i].reader.Close() } } // LibraryExport 已完成装载、可直接流式写出的导出包 type LibraryExport struct { Filename string full bool exportedAt time.Time books []preparedBook } // Close 释放导出过程中打开的全部文件句柄 func (e *LibraryExport) Close() { for i := range e.books { e.books[i].closeReaders() } } // BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。 // 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。 func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) { d, err := s.Get(id) if err != nil { return nil, err } b, err := s.prepareBook(d, "") if err != nil { return nil, err } name := d.Slug if name == "" { name = "book" } return &LibraryExport{ Filename: name + ".zip", full: false, exportedAt: time.Now(), books: []preparedBook{*b}, }, nil } // BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序) func (s *LibraryService) BuildAllExport() (*LibraryExport, error) { var docs []model.LibraryDoc if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil { return nil, err } details := s.attachFiles(docs) exp := &LibraryExport{ Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip", full: true, exportedAt: time.Now(), books: make([]preparedBook, 0, len(details)), } for i := range details { // slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底 dir := "docs/" + details[i].Slug + "/" if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) { exp.Close() return nil, errors.New("条目 slug 含非法字符,无法导出") } b, err := s.prepareBook(&details[i], dir) if err != nil { exp.Close() return nil, err } exp.books = append(exp.books, *b) } return exp, nil } // prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀) func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) { b := &preparedBook{dir: dir} b.meta = libraryExportBook{ ID: d.ID, CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339), Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description, CoverURL: d.CoverURL, CoverWidth: d.CoverWidth, CoverHeight: d.CoverHeight, Published: d.Published, SortOrder: d.SortOrder, EntriesAuto: d.EntriesAuto, Files: make([]libraryExportFile, 0, len(d.Files)), Sections: make([]libraryExportSection, 0, len(d.Sections)), } // 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序) keyByID := make(map[uint]string, len(d.Sections)) for i, sec := range d.Sections { keyByID[sec.ID] = "s" + strconv.Itoa(i) } for _, sec := range d.Sections { m := libraryExportSection{ Key: keyByID[sec.ID], Title: sec.Title, Content: sec.Content, SortOrder: sec.SortOrder, } if sec.ParentID != nil { m.ParentKey = keyByID[*sec.ParentID] } b.meta.Sections = append(b.meta.Sections, m) } // 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径 for _, f := range d.Files { m := libraryExportFile{ Name: f.Name, Ext: f.Ext, MIME: f.MIME, Size: f.Size, DownloadCount: f.DownloadCount, SortOrder: f.SortOrder, } full := filepath.Join(s.dir, f.StoredName) if r, err := os.Open(full); err == nil { m.Stored = "files/" + f.StoredName b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r}) } else { m.Missing = true } b.meta.Files = append(b.meta.Files, m) } // 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL if rel, ok := s.resolveCoverPath(d.CoverURL); ok { if ext := coverExtOf(d.CoverURL); ext != "" { if r, err := os.Open(rel); err == nil { b.coverReader = r b.coverPath = dir + "cover." + ext b.meta.CoverFile = "cover." + ext } } } return b, nil } // WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。 func (e *LibraryExport) WriteZip(w io.Writer) error { defer e.Close() zw := zip.NewWriter(w) index := make([]libraryExportIndexEntry, 0, len(e.books)) for i := range e.books { b := &e.books[i] // 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写 if e.full { if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil { return err } } if b.coverReader != nil { if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil { return err } } for j := range b.files { if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil { return err } } index = append(index, libraryExportIndexEntry{ Slug: b.meta.Slug, Title: b.meta.Title, Dir: b.dir, FileCount: len(b.meta.Files), SectionCount: len(b.meta.Sections), }) } // 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内) if e.full { root := libraryExportManifest{ Format: libraryExportFormat, FormatVersion: libraryExportVersion, ExportedAt: e.exportedAt.UTC().Format(time.RFC3339), Docs: index, } if err := writeZipJSON(zw, "library.json", root); err != nil { return err } } else { root := libraryExportManifest{ Format: libraryExportBookFormat, FormatVersion: libraryExportVersion, ExportedAt: e.exportedAt.UTC().Format(time.RFC3339), Doc: &e.books[0].meta, } if err := writeZipJSON(zw, "book.json", root); err != nil { return err } } return zw.Close() } // coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名 func coverExtOf(coverURL string) string { u := strings.TrimSpace(coverURL) if i := strings.IndexByte(u, '?'); i >= 0 { u = u[:i] } ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), ".")) if _, ok := libraryExportCoverExts[ext]; !ok { return "" } return ext } // writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读) func writeZipJSON(zw *zip.Writer, name string, v any) error { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) enc.SetIndent("", " ") if err := enc.Encode(v); err != nil { return err } hdr := &zip.FileHeader{Name: name, Method: zip.Deflate} hdr.SetMode(0o644) f, err := zw.CreateHeader(hdr) if err != nil { return err } _, err = f.Write(buf.Bytes()) return err } // writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU) func writeZipFile(zw *zip.Writer, name string, r io.Reader) error { hdr := &zip.FileHeader{Name: name, Method: zip.Store} hdr.SetMode(0o644) f, err := zw.CreateHeader(hdr) if err != nil { return err } _, err = io.Copy(f, r) return err }