feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

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

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

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