feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
369
backend/service/library_export.go
Normal file
369
backend/service/library_export.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package service
|
||||
|
||||
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
|
||||
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
|
||||
//
|
||||
// 单本 ZIP 布局:
|
||||
//
|
||||
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
|
||||
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
|
||||
// files/<stored-name> 附件原文件
|
||||
//
|
||||
// 全库 ZIP 布局:
|
||||
//
|
||||
// library.json 索引(每本书的目录位置)
|
||||
// docs/<slug>/book.json 各书清单
|
||||
// docs/<slug>/cover.<ext>
|
||||
// docs/<slug>/files/<stored-name>
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user