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,354 @@
package service
import (
"archive/zip"
"bytes"
"errors"
"image"
"image/jpeg"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
)
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
t.Helper()
var buf bytes.Buffer
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return buf.Bytes()
}
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
t.Helper()
p := filepath.Join(t.TempDir(), "export.zip")
f, err := os.Create(p)
if err != nil {
t.Fatalf("create temp zip: %v", err)
}
if err := exp.WriteZip(f); err != nil {
t.Fatalf("write export zip: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("close temp zip: %v", err)
}
return p
}
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
func writeZipFileMap(t *testing.T, entries map[string]string) string {
t.Helper()
buf := new(bytes.Buffer)
zw := zip.NewWriter(buf)
for name, content := range entries {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("zip create %s: %v", name, err)
}
if _, err := io.WriteString(w, content); err != nil {
t.Fatalf("zip write %s: %v", name, err)
}
}
if err := zw.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
p := filepath.Join(t.TempDir(), "in.zip")
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
t.Fatalf("write temp zip: %v", err)
}
return p
}
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
t.Helper()
if rep == nil {
t.Fatal("报告为空")
}
if rep.Total != total {
t.Fatalf("total = %d, want %d", rep.Total, total)
}
if len(rep.Failed) != 0 {
t.Fatalf("存在失败记录: %+v", rep.Failed)
}
if len(rep.Imported) != total {
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
}
}
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
func TestLibraryImportBookRoundTrip(t *testing.T) {
// ---- 源库 ----
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
docID, storedName := seedBookForExport(t, src, srcUploads)
// 制造两次下载,验证计数随包保留
var srcFile model.LibraryFile
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
t.Fatalf("find source file: %v", err)
}
src.IncrDownload(srcFile.ID)
src.IncrDownload(srcFile.ID)
exp, err := src.BuildBookExport(docID)
if err != nil {
t.Fatalf("build export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
// ---- 目标库(空库)----
dst, _ := newLibraryTestService(t)
dstUploads := t.TempDir()
dst.WithUploadsDir(dstUploads)
// 1) create 导入
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("import: %v", err)
}
assertReportOK(t, rep, 1)
item := rep.Imported[0]
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
t.Fatalf("导入条目异常: %+v", item)
}
var doc model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
t.Fatalf("find imported doc: %v", err)
}
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
t.Fatalf("元信息恢复不正确: %+v", doc)
}
if doc.Author != "老子" {
t.Fatalf("作者恢复不正确: %q", doc.Author)
}
if doc.CreatorID != 7 {
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
}
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
}
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
}
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
if !ok {
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
}
coverData, err := os.ReadFile(coverDisk)
if err != nil {
t.Fatalf("封面未落盘: %v", err)
}
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
t.Fatal("封面内容不是 JPEG")
}
// 章节树:章 + 小节父子关系
secs, err := dst.ListSections(doc.ID)
if err != nil {
t.Fatalf("list sections: %v", err)
}
if len(secs) != 2 {
t.Fatalf("章节数 = %d, want 2", len(secs))
}
var chapter, child *model.LibrarySection
for i := range secs {
if secs[i].ParentID == nil {
chapter = &secs[i]
} else {
child = &secs[i]
}
}
if chapter == nil || child == nil {
t.Fatalf("章节层级不正确: %+v", secs)
}
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
t.Fatalf("章信息不正确: %+v", chapter)
}
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
t.Fatalf("小节信息不正确: %+v", child)
}
// 附件:重新随机落盘、内容与下载计数保留
var files []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
t.Fatalf("list files: %v", err)
}
if len(files) != 1 {
t.Fatalf("附件数 = %d, want 1", len(files))
}
nf := files[0]
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
t.Fatalf("附件恢复不正确: %+v", nf)
}
data, err := os.ReadFile(dst.FilePath(&nf))
if err != nil {
t.Fatalf("附件未落盘: %v", err)
}
if string(data) != "EPUB-FILE-BYTES" {
t.Fatalf("附件内容不正确: %q", data)
}
firstCoverURL := doc.CoverURL
// 2) 再次 create:冲突跳过
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
t.Fatalf("冲突报告异常: %+v", rep2)
}
if rep2.Failed[0].Reason != libraryImportReasonConflict {
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
}
var docCount int64
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
}
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
if err != nil {
t.Fatalf("add extra: %v", err)
}
extraPath := dst.FilePath(extra)
if _, err := os.Stat(extraPath); err != nil {
t.Fatalf("extra 未先落盘: %v", err)
}
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
if err != nil {
t.Fatalf("overwrite import: %v", err)
}
assertReportOK(t, rep3, 1)
if rep3.Imported[0].Action != "overwritten" {
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
}
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
if docCount != 1 {
t.Fatalf("覆盖后书库数量异常: %d", docCount)
}
var doc2 model.LibraryDoc
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
t.Fatalf("find overwritten doc: %v", err)
}
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
}
if doc2.Author != "老子" || doc2.CreatorID != 7 {
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
}
var files2 []model.LibraryFile
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
}
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
}
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
}
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
}
if doc2.CoverURL == firstCoverURL {
t.Fatal("覆盖后封面应重新落盘为新文件")
}
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
if _, err := os.ReadFile(newCoverDisk); err != nil {
t.Fatalf("新封面未落盘: %v", err)
}
}
// TestLibraryImportAll 全库包往返
func TestLibraryImportAll(t *testing.T) {
src, _ := newLibraryTestService(t)
srcUploads := t.TempDir()
src.WithUploadsDir(srcUploads)
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
t.Fatalf("create second doc: %v", err)
}
seedBookForExport(t, src, srcUploads)
exp, err := src.BuildAllExport()
if err != nil {
t.Fatalf("build all export: %v", err)
}
zipPath := writeExportZipFile(t, exp)
exp.Close()
dst, _ := newLibraryTestService(t)
dst.WithUploadsDir(t.TempDir())
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
if err != nil {
t.Fatalf("import all: %v", err)
}
assertReportOK(t, rep, 2)
slugs := map[string]bool{}
for _, it := range rep.Imported {
slugs[it.Slug] = true
}
if !slugs["dao-jia"] || !slugs["second-book"] {
t.Fatalf("导入书集不正确: %+v", slugs)
}
}
// TestLibraryImportRejectBadPackage 非法包整体拒绝
func TestLibraryImportRejectBadPackage(t *testing.T) {
s, _ := newLibraryTestService(t)
s.WithUploadsDir(t.TempDir())
badPath := filepath.Join(t.TempDir(), "bad.zip")
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
}
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
}
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
}
}
// TestCleanZipRel 路径穿越校验
func TestCleanZipRel(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"", false},
{".", false},
{"..", false},
{"../evil", false},
{"a/../../b", false},
{"/etc/passwd", false},
{`docs\..\..\x`, false},
{"files/a.epub", true},
{"docs/x/book.json", true},
{`docs\x/cover.jpg`, true},
{"a//b", true},
}
for _, tc := range cases {
_, ok := cleanZipRel(tc.in)
if ok != tc.want {
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
}
}
}