package reader import ( "archive/zip" "bytes" "net/url" "os" "path/filepath" "strings" "testing" ) // 本文件:本地书籍(TXT / EPUB)导入与阅读的回归测试。 // newLocalBookService 复用登录测试的建库逻辑,另给一个临时 DataDir, // 避免本地书籍文件写到仓库目录里。 func newLocalBookService(t *testing.T) *ReaderService { t.Helper() svc, _ := newLoginTestService(t) svc.cfg.App.DataDir = t.TempDir() return svc } const sampleTXT = `书名:测试小说 作者:某某 第1章 开端 第一章的正文内容。 这里还有一行。 第2章 发展 第二章的正文内容。 第3章 结局 第三章的正文内容。 ` func TestSplitTXTChapters(t *testing.T) { chapters := splitTXTChapters(sampleTXT) if len(chapters) != 3 { t.Fatalf("章节数 = %d,期望 3:%+v", len(chapters), chapters) } wantTitles := []string{"第1章 开端", "第2章 发展", "第3章 结局"} for i, c := range chapters { if c.Title != wantTitles[i] { t.Errorf("第 %d 章标题 = %q,期望 %q", i+1, c.Title, wantTitles[i]) } } // 第一章要从文件头开始,把书名/作者并进去 if chapters[0].Start != 0 { t.Errorf("第一章起点 = %d,期望 0", chapters[0].Start) } if chapters[len(chapters)-1].End != len(sampleTXT) { t.Errorf("末章终点 = %d,期望 %d", chapters[len(chapters)-1].End, len(sampleTXT)) } // 区间必须首尾相接且不重叠 for i := 1; i < len(chapters); i++ { if chapters[i].Start != chapters[i-1].End { t.Errorf("第 %d/%d 章区间不连续:%d vs %d", i, i+1, chapters[i-1].End, chapters[i].Start) } } // 每章正文要能切出来且包含该章内容 body := sampleTXT[chapters[1].Start:chapters[1].End] if !strings.Contains(body, "第二章的正文内容") { t.Errorf("第二章区间内容不对: %q", body) } if strings.Contains(body, "第三章") { t.Errorf("第二章区间串到了第三章: %q", body) } } func TestSplitTXTChaptersFallback(t *testing.T) { chapters := splitTXTChapters("没有任何章节标记的一段文字。\n第二行。") if len(chapters) != 1 || chapters[0].Title != "全文" { t.Fatalf("无章标记应整本当一章: %+v", chapters) } if chapters[0].Start != 0 || chapters[0].End != len("没有任何章节标记的一段文字。\n第二行。") { t.Fatalf("整本区间不对: %+v", chapters[0]) } } func TestDecodeTextFileGBK(t *testing.T) { // "第1章 开端" 的 GBK 编码 gbk := []byte{0xB5, 0xDA, '1', 0xD5, 0xC2, ' ', 0xBF, 0xAA, 0xB6, 0xCB} got, charset := decodeTextFile(gbk) if charset != "gbk" { t.Fatalf("字符集 = %q,期望 gbk", charset) } if got != "第1章 开端" { t.Fatalf("解码结果 = %q", got) } } func TestImportLocalBookAndRead(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT)) if err != nil { t.Fatalf("导入失败: %v", err) } if !book.IsLocal || book.LocalPath == "" { t.Fatalf("导入后应标记为本地书籍: %+v", book) } if book.Name != "测试小说" { t.Errorf("书名 = %q", book.Name) } if book.TotalChapterNum != 3 { t.Errorf("总章数 = %d,期望 3", book.TotalChapterNum) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil || len(chapters) != 3 { t.Fatalf("目录 = %d 章, err=%v", len(chapters), err) } got, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) if err != nil { t.Fatalf("读第二章失败: %v", err) } if got.Type != "text" { t.Errorf("type = %q", got.Type) } if !strings.Contains(got.Content, "第二章的正文内容") { t.Errorf("第二章正文 = %q", got.Content) } if strings.Contains(got.Content, "第三章") || strings.Contains(got.Content, "第一章的正文") { t.Errorf("第二章正文串章了: %q", got.Content) } // 书名等前言应落在第一章 first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读第一章失败: %v", err) } if !strings.Contains(first.Content, "第一章的正文内容") { t.Errorf("第一章正文 = %q", first.Content) } // 越界与越权 if _, err := svc.LocalChapterContent(ctx, "u1", book.ID, 9); err == nil { t.Error("越界章节应报错") } if _, err := svc.LocalChapterContent(ctx, "other", book.ID, 0); err == nil { t.Error("他人书架应拒绝") } // 书架列表要带 is_local books, err := svc.ListBooks(ctx, "u1") if err != nil || len(books) != 1 || !books[0].IsLocal { t.Fatalf("书架列表未标记本地书籍: %+v, err=%v", books, err) } // 重复导入同名文件:覆盖更新且保留进度 if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第2章 发展"); err != nil { t.Fatal(err) } again, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT)) if err != nil { t.Fatalf("重复导入失败: %v", err) } if again.ID != book.ID { t.Errorf("同名导入应覆盖同一本书,得到新 ID %s", again.ID) } if again.DurChapterIndex != 1 { t.Errorf("章节数不变时应保留进度,得到 %d", again.DurChapterIndex) } // 移出书架要删掉落盘文件 path, err := svc.localFilePath(again) if err != nil { t.Fatal(err) } if err := svc.RemoveBook(ctx, "u1", again.ID); err != nil { t.Fatalf("移出书架失败: %v", err) } if _, err := os.ReadFile(path); err == nil { t.Error("移出书架后本地文件应被删除") } } // epubFixture 造一个最小可用的 EPUB(NCX 目录 + 两个 XHTML 章节)。 func epubFixture(t *testing.T) []byte { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } add("mimetype", "application/epub+zip") add("META-INF/container.xml", ` `) add("OEBPS/content.opf", ` `) add("OEBPS/toc.ncx", ` 第一章 起风 第二章 落雨 `) add("OEBPS/ch1.xhtml", `t

第一章 起风

第一段 & 实体。

第二段。

`) add("OEBPS/ch2.xhtml", `

第二章正文。


又一段。

`) if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes() } func TestImportEPUBAndRead(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() book, err := svc.ImportLocalBook(ctx, "u1", "测试电子书.epub", epubFixture(t)) if err != nil { t.Fatalf("导入 EPUB 失败: %v", err) } if book.TotalChapterNum != 2 { t.Fatalf("章数 = %d,期望 2", book.TotalChapterNum) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil || len(chapters) != 2 { t.Fatalf("目录 = %+v, err=%v", chapters, err) } if chapters[0].Title != "第一章 起风" || chapters[1].Title != "第二章 落雨" { t.Fatalf("章节标题应取自 NCX: %q / %q", chapters[0].Title, chapters[1].Title) } // 标题不要留在正文里 first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读第一章失败: %v", err) } if !strings.Contains(first.Content, "第一段 & 实体。") { t.Errorf("实体未解码: %q", first.Content) } if !strings.Contains(first.Content, "第一段 & 实体。\n第二段。") { t.Errorf("段落换行不对: %q", first.Content) } if strings.Contains(first.Content, "var x=1") { t.Errorf("script 应被剔除: %q", first.Content) } second, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) if err != nil || !strings.Contains(second.Content, "第二章正文。") { t.Fatalf("读第二章失败: %v / %q", err, second.Content) } } // TestImportEPUBWithImage 端到端:导入带图片的 EPUB,章节正文里图片应变成签名 // 地址,且该地址能取回原始图片字节。 func TestImportEPUBWithImage(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } imgBytes := []byte("\xff\xd8\xff\xe0fakejpeg") add("mimetype", "application/epub+zip") add("META-INF/container.xml", ` `) add("OEBPS/content.opf", ` `) add("OEBPS/nav.xhtml", ` `) add("OEBPS/ch1.xhtml", `

插图章

图片前。

图片后。

`) w, err := zw.Create("OEBPS/images/pic.jpg") if err != nil { t.Fatal(err) } if _, err := w.Write(imgBytes); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBook(ctx, "u1", "带图.epub", buf.Bytes()) if err != nil { t.Fatalf("导入失败: %v", err) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil || len(chapters) != 1 || chapters[0].Title != "插图章" { t.Fatalf("目录 = %+v, err=%v", chapters, err) } content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读正文失败: %v", err) } if !strings.Contains(content.Content, "图片前。") || !strings.Contains(content.Content, "图片后。") { t.Fatalf("正文缺内容: %q", content.Content) } var asset string for _, line := range strings.Split(content.Content, "\n") { if strings.HasPrefix(line, imgMarkerPrefix) { asset = strings.TrimPrefix(line, imgMarkerPrefix) } } if !strings.HasPrefix(asset, "/api/reader/local/asset?") { t.Fatalf("图片未换成签名地址: %q(正文 %q)", asset, content.Content) } u, err := url.Parse(asset) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("资源签名校验失败: %v", err) } data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil { t.Fatalf("读图片失败: %v", err) } if !bytes.Equal(data, imgBytes) { t.Fatalf("图片字节不一致: %q", data) } if ct != "image/jpeg" { t.Fatalf("Content-Type = %q", ct) } } func TestHTMLToText(t *testing.T) { got := htmlToText("
甲
\n

乙
丙

") if got != "甲\n乙\n丙" { t.Fatalf("htmlToText = %q", got) } } // TestParseEPUBTitlesHandlesNestedNavPoint 网上不少 EPUB(Epubor 导出)navPoint // 漏了闭合标签,标题会整棵挂进上一个 navPoint。按固定层级解会丢一大半标题, // 这里要求深层节点也能收全。 func TestParseEPUBTitlesHandlesNestedNavPoint(t *testing.T) { raw := []byte(` UnKnown 目录 小狗钱钱1 童话与理财 前言 `) titles := parseEPUBTitles(raw) want := map[string]string{ "text00000.html": "目录", "text00002.html": "小狗钱钱1", "text00005.html": "童话与理财", "text00006.html": "前言", } for href, title := range want { if titles[href] != title { t.Errorf("%s 标题 = %q,期望 %q(全部:%v)", href, titles[href], title, titles) } } if titles["UnKnown"] != "" { t.Errorf("docTitle 不该被当成章节标题:%v", titles) } } // TestParseEPUBTitlesNav 兼容 EPUB3 的 nav 目录(含带锚点与嵌套 ol 的情况)。 func TestParseEPUBTitlesNav(t *testing.T) { raw := []byte(` `) titles := parseEPUBTitles(raw) for href, title := range map[string]string{ "ch1.xhtml": "第一章 起风", "ch2.xhtml": "第二章 落雨", "ch3.xhtml": "第三章 天晴", } { if titles[href] != title { t.Errorf("%s 标题 = %q,期望 %q(全部:%v)", href, titles[href], title, titles) } } } // TestEpubHTMLToTextWithImages 图片要变成 [img]条目 标记行,且相对路径按正文所在 // 目录解析;标记行不能带缩进(前端据此渲染图片)。 func TestEpubHTMLToTextWithImages(t *testing.T) { src := `

第一章

正文一。

正文二尾巴。

` got := epubHTMLToText(src, "OEBPS") want := "第一章\n正文一。\n" + imgMarkerPrefix + filepath.Join("OEBPS", "Image00024.jpg") + "\n正文二\n" + imgMarkerPrefix + filepath.Join("OEBPS", "sub", "pic.png") + "\n尾巴。\n" + imgMarkerPrefix + "http://cdn.example.com/a.jpg" if got != want { t.Fatalf("epubHTMLToText =\n%q\n期望\n%q", got, want) } } // TestRewriteLocalImages 标记里的条目要换成签名地址,且重复处理不会二次改写。 func TestRewriteLocalImages(t *testing.T) { svc := newLocalBookService(t) text := imgMarkerPrefix + filepath.Join("OEBPS", "a.jpg") + "\n正文\n" + imgMarkerPrefix + "http://x/b.png" out := svc.rewriteLocalImages("book-1", text) lines := strings.Split(out, "\n") if !strings.HasPrefix(lines[0], imgMarkerPrefix+"/api/reader/local/asset?b=book-1&p=") { t.Fatalf("相对路径未改写:%q", lines[0]) } if !strings.HasSuffix(lines[2], "http://x/b.png") { t.Fatalf("外链图片不该改写:%q", lines[2]) } // 再跑一次应保持不变 if again := svc.rewriteLocalImages("book-1", out); again != out { t.Fatalf("重复改写改变了结果:%q", again) } // 签名可校验,改一个字符就不认 entry := filepath.Join("OEBPS", "a.jpg") u := svc.LocalAssetURL("book-1", entry) parsed, err := url.Parse(u) if err != nil { t.Fatal(err) } got, err := svc.VerifyLocalAssetURL("book-1", parsed.Query().Get("p"), parsed.Query().Get("s")) if err != nil || got != entry { t.Fatalf("签名校验失败: %v / %q", err, got) } if _, err := svc.VerifyLocalAssetURL("book-1", parsed.Query().Get("p"), "deadbeef"); err == nil { t.Fatal("错误签名应校验失败") } if _, err := svc.VerifyLocalAssetURL("book-2", parsed.Query().Get("p"), parsed.Query().Get("s")); err == nil { t.Fatal("换一本书后签名应失效") } } // epubWithCoverFixture 造一个带封面的最小 EPUB。opfCover 决定封面怎么写: // EPUB3 用 properties="cover-image",EPUB2 用 ;都为 false // 时封面只能靠文件名兜底。 func epubWithCoverFixture(t *testing.T, epub3, epub2 bool) ([]byte, []byte) { t.Helper() var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } coverBytes := []byte("\xff\xd8\xff\xe0fakecoverjpeg") add("mimetype", "application/epub+zip") add("META-INF/container.xml", ` `) meta := "" if epub2 { meta = `` } props := "" if epub3 { props = ` properties="cover-image"` } add("OEBPS/content.opf", ` `+meta+` `) add("OEBPS/ch1.xhtml", `

正文

内容。

`) w, err := zw.Create("OEBPS/images/cover.jpg") if err != nil { t.Fatal(err) } if _, err := w.Write(coverBytes); err != nil { t.Fatal(err) } if err := zw.Close(); err != nil { t.Fatal(err) } return buf.Bytes(), coverBytes } // TestImportEPUBCover 封面要能从 OPF 声明(EPUB3 / EPUB2)与文件名兜底三条路 // 解析出来,并转成可访问的签名资源地址。 func TestImportEPUBCover(t *testing.T) { ctx := t.Context() cases := []struct { name string epub3, epub2 bool }{ {"EPUB3 cover-image", true, false}, {"EPUB2 meta cover", false, true}, {"文件名兜底", false, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { svc := newLocalBookService(t) raw, coverBytes := epubWithCoverFixture(t, tc.epub3, tc.epub2) book, err := svc.ImportLocalBook(ctx, "u1", "有封面.epub", raw) if err != nil { t.Fatalf("导入失败: %v", err) } if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { t.Fatalf("封面未生成签名地址: %q", book.CoverURL) } u, err := url.Parse(book.CoverURL) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("封面签名校验失败: %v", err) } data, ct, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil { t.Fatalf("读封面失败: %v", err) } if !bytes.Equal(data, coverBytes) { t.Fatalf("封面字节不一致: %q", data) } if ct != "image/jpeg" { t.Fatalf("封面 MIME = %q,期望 image/jpeg", ct) } // 落库后书架列表/详情也要带上封面,重开服务不丢 books, err := svc.ListBooks(ctx, "u1") if err != nil || len(books) != 1 || books[0].CoverURL != book.CoverURL { t.Fatalf("书架封面 = %+v, err=%v", books, err) } }) } } // TestBackfillLocalCoverOnListBooks 早期导入的本地 EPUB 没存封面, // 加载书架时应该自动补上并落库,不必让用户重新导入。 func TestBackfillLocalCoverOnListBooks(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() raw, coverBytes := epubWithCoverFixture(t, true, false) book, err := svc.ImportLocalBook(ctx, "u1", "老书.epub", raw) if err != nil { t.Fatalf("导入失败: %v", err) } // 模拟修复前导入的旧数据:库里没有封面 book.CoverURL = "" if err := svc.repo.UpdateBook(ctx, book); err != nil { t.Fatal(err) } books, err := svc.ListBooks(ctx, "u1") if err != nil || len(books) != 1 { t.Fatalf("书架 = %+v, err=%v", books, err) } restored := books[0].CoverURL if !strings.HasPrefix(restored, "/api/reader/local/asset?") { t.Fatalf("书架加载未回填封面: %q", restored) } // 已经写回数据库,下次不用再解析 stored, err := svc.repo.GetBook(ctx, book.ID) if err != nil || stored.CoverURL != restored { t.Fatalf("封面未落库: %+v, err=%v", stored, err) } // 回填出来的地址必须真能取到封面图 u, err := url.Parse(restored) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("回填封面签名校验失败: %v", err) } data, _, err := svc.ReadLocalAsset(ctx, book.ID, entry) if err != nil || !bytes.Equal(data, coverBytes) { t.Fatalf("回填封面取回失败: %v / %q", err, data) } } // TestBackfillLocalCoverSkipsTXTAndCoveredBooks TXT 没有内嵌图片,已有封面的书 // 也不该被重复处理。 func TestBackfillLocalCoverSkipsTXTAndCoveredBooks(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() txt, err := svc.ImportLocalBook(ctx, "u1", "小说.txt", []byte(sampleTXT)) if err != nil { t.Fatalf("导入 TXT 失败: %v", err) } if svc.BackfillLocalCover(ctx, txt) { t.Error("TXT 不该被回填封面") } raw, _ := epubWithCoverFixture(t, true, false) epub, err := svc.ImportLocalBook(ctx, "u1", "已带封面.epub", raw) if err != nil { t.Fatalf("导入 EPUB 失败: %v", err) } if svc.BackfillLocalCover(ctx, epub) { t.Error("已有封面的书不该被回填") } } // TestImportLocalBookFromPath 服务器选书:原地引用不复制,章节可读, // 移出书架不删除源文件。 func TestImportLocalBookFromPath(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() srcDir := t.TempDir() srcPath := filepath.Join(srcDir, "服务器上的书.txt") if err := os.WriteFile(srcPath, []byte(sampleTXT), 0o640); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBookFromPath(ctx, "u1", srcPath) if err != nil { t.Fatalf("从服务器路径导入失败: %v", err) } if !book.LocalExternal { t.Fatal("应标记为原地引用") } if book.LocalPath != srcPath { t.Fatalf("LocalPath = %q,期望源文件路径 %q", book.LocalPath, srcPath) } if book.TotalChapterNum != 3 { t.Fatalf("章节数 = %d,期望 3", book.TotalChapterNum) } if book.Name != "服务器上的书" { t.Fatalf("书名 = %q", book.Name) } content, err := svc.LocalChapterContent(ctx, "u1", book.ID, 0) if err != nil { t.Fatalf("读正文失败: %v", err) } if !strings.Contains(content.Content, "第一章的正文内容。") { t.Fatalf("正文不对: %q", content.Content) } // 不应把文件复制进 data/reader/local localDir, err := svc.localBooksDir() if err != nil { t.Fatal(err) } entries, err := os.ReadDir(localDir) if err != nil { t.Fatal(err) } if len(entries) != 0 { t.Fatalf("原地引用不该产生托管副本,目录里有 %d 个文件", len(entries)) } // 移出书架只解除引用,源文件必须还在 if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil { t.Fatalf("移出书架失败: %v", err) } if _, err := os.Stat(srcPath); err != nil { t.Fatalf("原地引用的源文件被删除了: %v", err) } } // TestImportLocalBookFromPathRejectsUnsupported 只接受 TXT / EPUB,目录与超限文件要报错。 func TestImportLocalBookFromPathRejectsUnsupported(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() dir := t.TempDir() pdf := filepath.Join(dir, "book.pdf") if err := os.WriteFile(pdf, []byte("%PDF-1.4"), 0o640); err != nil { t.Fatal(err) } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", pdf); err == nil { t.Fatal("PDF 不该被接受") } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", dir); err == nil { t.Fatal("目录不该被接受") } if _, err := svc.ImportLocalBookFromPath(ctx, "u1", filepath.Join(dir, "nope.txt")); err == nil { t.Fatal("不存在的文件应报错") } } // TestImportLocalAudioDir 目录导入有声书:音频与 .strm 成为章节,按相对路径排序, // 其它文件忽略;.strm 指向远端走媒体代理,指向本地文件走本地音频端点。 func TestImportLocalAudioDir(t *testing.T) { svc := newLocalBookService(t) ctx := t.Context() base := t.TempDir() dir := filepath.Join(base, "有声书") shared := filepath.Join(base, "共享") for _, d := range []string{filepath.Join(dir, "00-cd1"), shared} { if err := os.MkdirAll(d, 0o750); err != nil { t.Fatal(err) } } write := func(path, body string) { if err := os.WriteFile(path, []byte(body), 0o640); err != nil { t.Fatal(err) } } // 相对路径排序:00-cd1/ 子目录在前,其后是同级文件 write(filepath.Join(dir, "00-cd1", "01-正文.mp3"), "mp3-bytes-2") write(filepath.Join(dir, "01-开场.mp3"), "mp3-bytes") write(filepath.Join(dir, "02-远端.strm"), "https://cdn.example.com/a.mp3\n") localTarget := filepath.Join(shared, "尾巴.flac") write(localTarget, "flac-bytes") write(filepath.Join(dir, "03-本地.strm"), localTarget+"\n") // 非音频文件应被忽略 write(filepath.Join(dir, "cover.jpg"), "jpg") write(filepath.Join(dir, "readme.txt"), "txt") book, err := svc.ImportLocalAudioDir(ctx, "u1", dir) if err != nil { t.Fatalf("导入有声书目录失败: %v", err) } if book.Type != 1 { t.Fatalf("Type = %d,期望 1(音频)", book.Type) } if !book.LocalExternal || book.LocalPath != dir { t.Fatalf("应原地引用目录,得到 LocalPath=%q external=%v", book.LocalPath, book.LocalExternal) } if book.Name != "有声书" { t.Fatalf("书名应取目录名,得到 %q", book.Name) } if book.TotalChapterNum != 4 { t.Fatalf("章节数 = %d,期望 4(忽略 cover.jpg / readme.txt)", book.TotalChapterNum) } chapters, err := svc.ListChapters(ctx, book.ID) if err != nil { t.Fatal(err) } wantTitles := []string{"01-正文", "01-开场", "02-远端", "03-本地"} for i, want := range wantTitles { if chapters[i].Title != want { t.Fatalf("第 %d 章标题 = %q,期望 %q(完整目录 %+v)", i, chapters[i].Title, want, chapters) } } // 本地音频文件 → 本地音频流端点 first, err := svc.LocalChapterContent(ctx, "u1", book.ID, 1) if err != nil { t.Fatalf("读第 2 章失败: %v", err) } if first.Type != "audio" || len(first.Tracks) != 1 { t.Fatalf("第 2 章应为单轨音频: %+v", first) } if !strings.HasPrefix(first.Tracks[0], "/api/reader/local/audio?") { t.Fatalf("本地音频应走本地流端点: %q", first.Tracks[0]) } // .strm 指向远端 → 媒体代理 remote, err := svc.LocalChapterContent(ctx, "u1", book.ID, 2) if err != nil { t.Fatalf("读第 3 章失败: %v", err) } if !strings.HasPrefix(remote.Tracks[0], "/api/reader/media?") { t.Fatalf("远端 .strm 应走媒体代理: %q", remote.Tracks[0]) } // .strm 指向本地文件 → 本地音频流端点 local, err := svc.LocalChapterContent(ctx, "u1", book.ID, 3) if err != nil { t.Fatalf("读第 4 章失败: %v", err) } if !strings.HasPrefix(local.Tracks[0], "/api/reader/local/audio?") { t.Fatalf("本地 .strm 应走本地流端点: %q", local.Tracks[0]) } u, err := url.Parse(local.Tracks[0]) if err != nil { t.Fatal(err) } resolved, err := svc.VerifyLocalAudioURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("音频签名校验失败: %v", err) } if resolved != localTarget { t.Fatalf("解析出的音频路径 = %q,期望 %q", resolved, localTarget) } } // TestImportLocalAudioDirNoAudio 目录里没有音频时应给出明确错误。 func TestImportLocalAudioDirNoAudio(t *testing.T) { svc := newLocalBookService(t) dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o640); err != nil { t.Fatal(err) } if _, err := svc.ImportLocalAudioDir(t.Context(), "u1", dir); err == nil { t.Fatal("没有音频文件时应报错") } } // TestLocalAudioURLSignature 音频流地址的签名可校验、改一处即失效。 func TestLocalAudioURLSignature(t *testing.T) { svc := newLocalBookService(t) path := filepath.Join("media", "有声书", "01.mp3") raw := svc.LocalAudioURL("book-1", path) u, err := url.Parse(raw) if err != nil { t.Fatal(err) } if !strings.HasPrefix(raw, "/api/reader/local/audio?b=book-1&p=") { t.Fatalf("音频地址格式不对: %q", raw) } got, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), u.Query().Get("s")) if err != nil || got != path { t.Fatalf("签名校验失败: %v / %q", err, got) } if _, err := svc.VerifyLocalAudioURL("book-1", u.Query().Get("p"), "deadbeef"); err == nil { t.Fatal("错误签名应校验失败") } if _, err := svc.VerifyLocalAudioURL("book-2", u.Query().Get("p"), u.Query().Get("s")); err == nil { t.Fatal("换一本书后签名应失效") } } // TestIsRemoteMediaURL 区分远端地址与本地路径。 func TestIsRemoteMediaURL(t *testing.T) { remote := []string{ "https://cdn.example.com/a.mp3", "http://x/y.flac", "webdav://host/a.mp3", "/api/strm/play/local/abc", } for _, raw := range remote { if !isRemoteMediaURL(raw) { t.Errorf("%q 应判为远端", raw) } } local := []string{ "本地音频.flac", `D:\media\有声书\01.mp3`, "sub/02.mp3", } for _, raw := range local { if isRemoteMediaURL(raw) { t.Errorf("%q 应判为本地路径", raw) } } } // TestImportEPUBWithoutCover 没有封面图时不应硬凑,CoverURL 保持为空。 func TestImportEPUBWithoutCover(t *testing.T) { svc := newLocalBookService(t) book, err := svc.ImportLocalBook(t.Context(), "u1", "无封面.epub", epubFixture(t)) if err != nil { t.Fatalf("导入失败: %v", err) } if book.CoverURL != "" { t.Fatalf("无封面书籍不该有 cover_url: %q", book.CoverURL) } } // TestImportEPUBCoverFallbackOnBrokenDeclaration 声明的封面 id 指不到条目时, // 应退回“文件名像封面”的图片,而不是直接没有封面。 func TestImportEPUBCoverFallbackOnBrokenDeclaration(t *testing.T) { svc := newLocalBookService(t) coverBytes := []byte("\xff\xd8\xff\xe0fallbackjpeg") var buf bytes.Buffer zw := zip.NewWriter(&buf) add := func(name, body string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } add("mimetype", "application/epub+zip") add("META-INF/container.xml", ` `) // meta 指向一个 manifest 里不存在的 id add("OEBPS/content.opf", ` `) add("OEBPS/ch1.xhtml", `

内容。

`) // 声明失效,只能靠文件名(含“封面”)兜底 add("OEBPS/images/封面.png", string(coverBytes)) if err := zw.Close(); err != nil { t.Fatal(err) } book, err := svc.ImportLocalBook(t.Context(), "u1", "兜底.epub", buf.Bytes()) if err != nil { t.Fatalf("导入失败: %v", err) } if !strings.HasPrefix(book.CoverURL, "/api/reader/local/asset?") { t.Fatalf("应兜底找到封面: %q", book.CoverURL) } u, err := url.Parse(book.CoverURL) if err != nil { t.Fatal(err) } entry, err := svc.VerifyLocalAssetURL(u.Query().Get("b"), u.Query().Get("p"), u.Query().Get("s")) if err != nil { t.Fatalf("封面签名校验失败: %v", err) } if filepath.Base(entry) != "封面.png" { t.Fatalf("兜底应命中封面.png,实际 %q", entry) } data, _, err := svc.ReadLocalAsset(t.Context(), book.ID, entry) if err != nil || !bytes.Equal(data, coverBytes) { t.Fatalf("读兜底封面失败: %v / %q", err, data) } }