From 47c8bfdd6c6950411cd36f61b393820d838de316 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:38:18 +0800 Subject: [PATCH] =?UTF-8?q?bug=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/reader/book_type_fallback_test.go | 149 ++++++++++++++++++ internal/service/reader/reader.go | 32 +++- web/src/pages/reader/ReaderBookPage.tsx | 14 +- 3 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 internal/service/reader/book_type_fallback_test.go diff --git a/internal/service/reader/book_type_fallback_test.go b/internal/service/reader/book_type_fallback_test.go new file mode 100644 index 0000000..09e8c22 --- /dev/null +++ b/internal/service/reader/book_type_fallback_test.go @@ -0,0 +1,149 @@ +package reader + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// 本文件:书籍类型兜底的回归测试。 +// +// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本), +// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 > +// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来, +// 整个阅读页是一屏 URL(用户截图里的现象)。 +// +// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的 +// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。 + +// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。 +func audioTestSourceJSON(t *testing.T, base string) string { + t.Helper() + return `{ + "bookSourceName": "听书测试源", + "bookSourceType": 1, + "bookSourceUrl": "` + base + `", + "ruleContent": {"content": "$.data.url"} +}` +} + +// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染, +// 并把纠正后的类型写回书架记录。 +func TestAudioBookTypeFallbackBySource(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL)) + + // 复刻详情页写死的 origin_type=0 + book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{ + SourceID: sourceID, + Origin: srv.URL, + OriginName: "听书测试源", + OriginType: 0, + BookURL: srv.URL + "/book/1", + }, "测试听书", "某作者", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{ + {Index: 0, Title: "第一章", URL: srv.URL + "/content"}, + }); err != nil { + t.Fatalf("保存目录失败: %v", err) + } + + out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0) + if err != nil { + t.Fatalf("取正文失败: %v", err) + } + if out.Type != "audio" { + t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q", + out.Type, out.Content) + } + if len(out.Tracks) != 1 { + t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks)) + } + // 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头) + if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") { + t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0]) + } + raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s")) + if err != nil { + t.Fatalf("代理地址校验失败: %v", err) + } + if raw != "https://cdn.example.com/a.m4a" { + t.Fatalf("代理还原地址 = %q", raw) + } + + // 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的 + // book.type 也一直是错的(书源 JS 会读它分支)。 + reloaded, err := svc.GetBook(ctx, book.ID) + if err != nil { + t.Fatalf("重读书籍失败: %v", err) + } + if reloaded.Type != 1 { + t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type) + } +} + +// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底: +// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。 +func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`)) + })) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + // 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8) + src := `{ + "bookSourceName": "声明文本的音频源", + "bookSourceType": 1, + "bookSourceUrl": "` + srv.URL + `", + "ruleContent": {"content": "@js:book.type = 8; result"} +}` + sourceID := prepareLoginSource(t, svc, src) + + book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{ + SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源", + OriginType: 0, BookURL: srv.URL + "/book/1", + }, "声明文本", "某作者", "") + if err != nil { + t.Fatalf("加入书架失败: %v", err) + } + if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{ + {Index: 0, Title: "第一章", URL: srv.URL + "/content"}, + }); err != nil { + t.Fatalf("保存目录失败: %v", err) + } + + out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0) + if err != nil { + t.Fatalf("取正文失败: %v", err) + } + if out.Type != "text" { + t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type) + } +} + +// trackQueryValue 取出代理地址里的查询参数值(测试用)。 +func trackQueryValue(t *testing.T, raw, key string) string { + t.Helper() + idx := strings.Index(raw, key+"=") + if idx < 0 { + t.Fatalf("地址 %q 里没有参数 %s", raw, key) + } + rest := raw[idx+len(key)+1:] + if end := strings.Index(rest, "&"); end >= 0 { + rest = rest[:end] + } + return rest +} diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index bdab9b2..609b5a3 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -1139,6 +1139,21 @@ func normalizeBookType(t int) int { return 0 } +// contentTypeCode 把 ChapterContent.Type 换算回书架记录的类型码 +// (0 文本 / 1 音频 / 2 图片,与 model.ReaderBook.Type 同一套约定)。 +// 第二个返回值为 false 表示这个渲染类型没有对应的书籍类型(如视频)。 +func contentTypeCode(t string) (int, bool) { + switch t { + case "text": + return 0, true + case "audio": + return 1, true + case "image": + return 2, true + } + return 0, false +} + // GetToc 抓取目录。返回值中的 declaredType 是书源在规则 JS 里声明的书籍类型 // (-1 表示未声明),书源用它在目录阶段把听书/漫画/短剧源标成对应类型。 func (s *ReaderService) GetToc(ctx context.Context, userID, sourceID, sourceURL, bookURL, tocURL string) ([]TocChapter, error) { @@ -1655,7 +1670,14 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo if declaredType >= 0 { effective = declaredType } - if effective < 0 { + // 书架类型还停在默认的「文本」时,用书源声明的类型兜底。 + // + // 书籍详情页的「加入书架/开始阅读」曾经把 origin_type 写死成 0(见 web + // ReaderBookPage),听书/漫画书于是以文本类型落库;而书架类型优先级高于 + // 书源类型,正文就被当文本渲染——听书源的播放直链会排满整个阅读页。 + // 规则 JS 里显式声明的类型(declaredType)仍然最优先,所以「文本型聚合源 + // 提供听书/漫画内容」这类靠 JS 声明类型的情形不受影响。 + if declaredType < 0 && effective <= 0 { effective = src.Type } switch effective { @@ -2257,6 +2279,14 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { s.log.Warn("reader: 写回书籍类型失败", zap.String("book", book.ID), zap.Error(err)) } + } else if code, ok := contentTypeCode(out.Type); ok && code != 0 && code != book.Type { + // 书源类型兜底纠正出来的音频/漫画类型也落库:这些书被详情页写死的 + // origin_type=0 记成了文本,不写回的话每读一章都要再纠正一次, + // 注入规则 JS 的 book.type 也一直是错的。只向上纠正,不覆盖成文本。 + book.Type = code + if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil { + s.log.Warn("reader: 修正书籍类型失败", zap.String("book", book.ID), zap.Error(err)) + } } if out.Type == "text" { out.Content = s.applyUserReplaceRules(ctx, userID, book.Name, out.Content) diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx index 73889b2..739e48e 100644 --- a/web/src/pages/reader/ReaderBookPage.tsx +++ b/web/src/pages/reader/ReaderBookPage.tsx @@ -24,6 +24,8 @@ export default function ReaderBookPage() { const [chapters, setChapters] = useState(null) const [shelfBook, setShelfBook] = useState(null) const [busy, setBusy] = useState(false) + // 当前书源声明的类型(1 音频 / 2 图片),加入书架时写进来源记录 + const [sourceType, setSourceType] = useState(0) const [tocExpanded, setTocExpanded] = useState(false) const [introExpanded, setIntroExpanded] = useState(false) // 换源:候选源来自按书名重新搜索的结果 @@ -58,6 +60,16 @@ export default function ReaderBookPage() { .catch(() => setChapters([])) }, [bookURL, sourceURL]) + // 书源声明的类型(1 音频 / 2 图片)。加入书架时要带上它:写死成 0(文本)会让 + // 听书源的书以文本类型落库,阅读器就把播放直链当正文排版出来,根本播不了。 + useEffect(() => { + if (!sourceURL) return + readerAPI + .listSources() + .then((sources) => setSourceType(sources.find((s) => s.source_url === sourceURL)?.type ?? 0)) + .catch(() => undefined) + }, [sourceURL]) + // 详情返回的 tocUrl 更准确,拿到后重新拉目录 useEffect(() => { const tocURL = info?.toc_url @@ -84,7 +96,7 @@ export default function ReaderBookPage() { source_id: originID, origin: sourceURL, origin_name: originName || info?.name || '', - origin_type: 0, + origin_type: sourceType, book_url: bookURL, latest_chapter: info?.latest_chapter ?? '', },