新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
370 lines
11 KiB
Go
370 lines
11 KiB
Go
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
|
||
}
|