Files
jiang13-bbs/backend/service/library_export.go
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

370 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}