feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
354
backend/service/library_import_test.go
Normal file
354
backend/service/library_import_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user