This commit is contained in:
truewhile
2026-10-01 23:29:49 +08:00
parent 9036ad04e7
commit 6584519bd8
20 changed files with 1072 additions and 79 deletions
+24
View File
@@ -57,6 +57,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
@@ -414,6 +416,28 @@ func readerAddBookHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerSwitchOriginHandler 换源:把书架里的书切到另一个书源。
// 阅读进度保留;旧源目录缓存清空后按新源后台重新预热。
func readerSwitchOriginHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Origin reader.SearchOrigin `json:"origin" binding:"required"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
userID := c.GetString(middleware.CtxUserID)
book, err := svc.Reader.SwitchOrigin(c.Request.Context(), userID, c.Param("id"), body.Origin)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
svc.Reader.WarmUpBookChaptersAsync(c.Request.Context(), userID, book)
c.JSON(http.StatusOK, book)
}
}
// readerImportLocalBookHandler 上传本地书籍(TXT / EPUB)并加入书架。
// 正文落盘到 data/reader/local,目录切分后与网络书籍共用阅读器链路。
func readerImportLocalBookHandler(svc *service.Container) gin.HandlerFunc {
+38
View File
@@ -0,0 +1,38 @@
package reader
import "testing"
// 聚合类书源(如「光遇聚合」)在没有封面时会把搜索参数信封当封面返回,
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 <img> 显示破图。
func TestNormalizeCoverURL(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"blank", " ", ""},
{"https", "https://img.example.com/a.jpg", "https://img.example.com/a.jpg"},
{"http", "http://img.example.com/a.jpg", "http://img.example.com/a.jpg"},
{"uppercase scheme", "HTTPS://img.example.com/a.jpg", "HTTPS://img.example.com/a.jpg"},
{"trimmed", " https://img.example.com/a.jpg ", "https://img.example.com/a.jpg"},
{"inline image data uri", "data:image/png;base64,iVBORw0KGgo=", "data:image/png;base64,iVBORw0KGgo="},
{"local asset url", "/api/reader/local/asset?b=1&p=2&s=3", "/api/reader/local/asset?b=1&p=2&s=3"},
{
"search params envelope",
"data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ==",
"",
},
{"text data uri", "data:text/plain;base64,aGk=", ""},
{"source name", "光遇聚合", ""},
{"javascript url", "javascript:alert(1)", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := normalizeCoverURL(tc.in); got != tc.want {
t.Fatalf("normalizeCoverURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
+117 -3
View File
@@ -718,6 +718,9 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook {
k := b.Name + "|" + b.Author
if existing, ok := merged[k]; ok {
existing.Origins = append(existing.Origins, b.Origins...)
// 同书多源时补齐缺失字段:legado 是「一源一行」,各源自己显示拿到的
// 信息;MeBox 合并成一行,若不补齐,先到的空值会挡掉后面源的有效值。
fillMissingSearchBookFields(existing, &b)
continue
}
merged[k] = &b
@@ -739,6 +742,29 @@ func mergeSearchResults(hits []searchHit, key string) []SearchBook {
return out
}
// fillMissingSearchBookFields 把同书多源结果里另一份的有效字段补进合并结果。
//
// 同一个聚合源的不同上游、或不同书源,对同一本书的覆盖能力不同:有的能给封面、
// 简介、字数、最新章节,有的只给书名。合并成一行时必须补齐,否则先到的空值会把
// 后面源的有效值挡掉——典型表现就是「这本书明明有源带封面,列表里却是空白」。
func fillMissingSearchBookFields(dst, src *SearchBook) {
if dst.CoverURL == "" {
dst.CoverURL = src.CoverURL
}
if dst.Intro == "" {
dst.Intro = src.Intro
}
if dst.Kind == "" {
dst.Kind = src.Kind
}
if dst.WordCount == "" {
dst.WordCount = src.WordCount
}
if dst.LatestChapter == "" {
dst.LatestChapter = src.LatestChapter
}
}
// searchInSource 单源搜索(对应 WebBook.searchBook)。
func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, key string, page int) ([]SearchBook, error) {
if bs == nil {
@@ -800,7 +826,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
WordCount: wordCount,
LatestChapter: lastChapter,
Intro: intro,
CoverURL: cover,
CoverURL: normalizeCoverURL(cover),
BookURL: bookURL,
Origins: []SearchOrigin{{
SourceID: src.ID,
@@ -835,6 +861,33 @@ func firstNonEmpty(vals ...string) string {
return ""
}
// normalizeCoverURL 过滤书源规则给出的封面地址,只保留浏览器能真正渲染成图片的
// 值,其余一律返回空串(前端据此显示占位图标,而不是破图)。
//
// 背景:书源在「这本书没有封面」时不一定返回空值。legado 语义下
// getString(rule, isUrl=true) 取值为空会回退成 baseUrl,而聚合类书源
// (如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封,
// 于是封面上会落一串 data: 文本,<img> 按 text/plain 处理必然破图。
// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
// (/api/reader/local/asset?...)。
func normalizeCoverURL(raw string) string {
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
lower := strings.ToLower(u)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
return u
case strings.HasPrefix(lower, "data:image/"):
return u
case strings.HasPrefix(u, "/"):
return u
default:
return ""
}
}
// ─── 详情 / 目录 / 正文 ─────────────────────────────────────────────────────
// BookInfo 书籍详情(对应 legado Book 信息页)。
@@ -912,7 +965,7 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
info.Intro = v
}
if v, err := ar.GetString(SPtr(bir.CoverURL), nil, true); err == nil && v != "" {
info.CoverURL = v
info.CoverURL = normalizeCoverURL(v)
}
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
info.TocURL = v
@@ -1300,7 +1353,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
BookURL: origin.BookURL,
Name: name,
Author: author,
CoverURL: coverURL,
CoverURL: normalizeCoverURL(coverURL),
Type: origin.OriginType,
}
if err := s.repo.CreateBook(ctx, book); err != nil {
@@ -1309,6 +1362,67 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
return book, nil
}
// SwitchOrigin 换源:把书架里的书切到另一个书源(对应 legado 的「换源」)。
//
// 换源要同时处理三件事:
// 1. 来源字段整体换成新源(origin / origin_name / book_url / toc_url);
// 2. 旧源缓存的目录必须清掉——章节地址只对旧源有效,留着会让阅读器读到错内容;
// 3. 阅读进度保留(按章节序号定位,与 legado 的做法一致)。
//
// 新源详情是「尽力而为」:抓得到就用它的 tocUrl(以及当前封面为空时的封面),
// 抓不到就把 tocUrl 留空让目录回落到书籍地址重新解析,不让一次网络抖动挡住换源。
func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string, origin SearchOrigin) (*model.ReaderBook, error) {
book, err := s.repo.GetBook(ctx, bookID)
if err != nil {
return nil, err
}
if book.UserID != userID {
return nil, fmt.Errorf("无权操作他人书架")
}
if book.LocalPath != "" {
return nil, fmt.Errorf("本地导入的书籍没有书源,无法换源")
}
target := strings.TrimSpace(origin.BookURL)
if target == "" {
return nil, fmt.Errorf("缺少目标书源的书本地址")
}
if _, _, err := s.loadSourceFlexible(ctx, origin.SourceID, origin.Origin); err != nil {
return nil, err
}
if book.BookURL == target && book.Origin == origin.Origin {
return book, nil // 已经是这个源,重复点击视为成功
}
info, infoErr := s.GetBookInfo(ctx, origin.SourceID, origin.Origin, target)
if infoErr != nil && s.log != nil {
s.log.Warn("reader: 换源时读取新源详情失败",
zap.String("book", book.ID), zap.String("origin", origin.Origin), zap.Error(infoErr))
}
book.Origin = origin.Origin
book.OriginName = firstNonEmpty(origin.OriginName, book.OriginName)
book.BookURL = target
book.TocURL = ""
if info != nil {
book.TocURL = strings.TrimSpace(info.TocURL)
if book.CoverURL == "" {
book.CoverURL = normalizeCoverURL(info.CoverURL)
}
if info.LatestChapter != "" {
book.LatestChapterTitle = info.LatestChapter
}
}
// 目录是旧源的缓存,必须清空;章节数一并归零,等新源目录重新预热后再算未读。
book.TotalChapterNum = 0
if err := s.repo.ReplaceChapters(ctx, book.ID, nil); err != nil {
return nil, err
}
if err := s.repo.UpdateBook(ctx, book); err != nil {
return nil, err
}
return book, nil
}
// GetBook 按 ID 取书(媒体代理等使用)。
func (s *ReaderService) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
return s.repo.GetBook(ctx, id)
+19 -6
View File
@@ -248,10 +248,16 @@ func (a *AnalyzeRule) putRule(putMap map[string]string) error {
return nil
}
func (a *AnalyzeRule) makeDeps() *RuleDeps {
// makeDeps 构造 MakeUpRule 展开内嵌 {{...}} 需要的执行环境。
//
// content 是当前正在解析的元素。legado 的内嵌规则({{$.x}} / {{@x}})在**当前元素**
// 上求值:书源普遍用 `{{$.status}},{{$.score}}` 这种模板拼 kind、用
// `{{$.source}} {{$.last_chapter_title}}` 拼最新章节。若拿整份响应去求值,
// 这些模板会全部取空——表现为 kind=",,,"、"最新"为空(聚合源尤其明显)。
func (a *AnalyzeRule) makeDeps(content any) *RuleDeps {
return &RuleDeps{
JS: a.evalJS,
Rule: func(rule string) (string, error) { return a.GetString(rule, nil, false) },
Rule: func(rule string) (string, error) { return a.GetString(rule, content, false) },
Get: a.Get,
}
}
@@ -279,7 +285,7 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return nil, err
}
@@ -378,7 +384,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
if err := a.putRule(sourceRule.putMap); err != nil {
return "", err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return "", err
}
@@ -423,7 +429,14 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
}
if isUrl {
if strings.TrimSpace(str) == "" {
return a.baseUrl, nil
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
// 但 baseUrl 未必是地址:聚合类书源(如「光遇聚合」)的搜索请求地址
// 本身就是 data:;base64,... 参数信封,直接回退会把信封当成封面/书址
// 返回,前端 <img> 只能显示破图。故仅在 baseUrl 是 http(s) 地址时回退。
if base := strings.TrimSpace(a.baseUrl); isAbsURL(base) {
return base, nil
}
return "", nil
}
return a.absolutize(str), nil
}
@@ -444,7 +457,7 @@ func (a *AnalyzeRule) GetElement(ruleStr string) (any, error) {
if err := a.putRule(sourceRule.putMap); err != nil {
return nil, err
}
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps())
resolved, err := sourceRule.MakeUpRule(result, a.makeDeps(result))
if err != nil {
return nil, err
}
@@ -0,0 +1,60 @@
package rule
import "testing"
// 聚合类书源(如「光遇聚合」)的搜索请求地址本身就是 data:;base64,... 参数信封。
// 规则取值为空时 legado 语义会回退 baseUrl;若此时的 baseUrl 不是 http(s) 地址,
// 回退结果会把封面/书址污染成非图片文本(前端 <img> 直接破图)。
func TestGetStringURLFallbackSkipsNonHTTPBaseURL(t *testing.T) {
const payload = `{"data":[{"book_name":"全球高武","author":"老鹰吃小鸡"}]}`
const envelope = "data:;base64,eyJrZXkiOiLlhajnkIPpq5jmraYiLCJ0YWIiOiLlsI/or7QiLCJzb3VyY2VzS2V5Ijoi5YWo6YOoIiwicGFnZSI6MSwiZGlzYWJsZWRfc291cmNlcyI6IjAifQ=="
elementOf := func(t *testing.T, content, baseURL string) (*AnalyzeRule, any) {
t.Helper()
a := NewAnalyzeRule()
a.SetContent(content, baseURL)
els, err := a.GetElements("$.data")
if err != nil {
t.Fatalf("GetElements: %v", err)
}
if len(els) != 1 {
t.Fatalf("GetElements: want 1 element, got %d", len(els))
}
return a, els[0]
}
t.Run("空取值不会把 data: 信封当地址返回", func(t *testing.T) {
a, el := elementOf(t, payload, envelope)
got, err := a.GetString("$.thumb_url", el, true)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != "" {
t.Fatalf("封面应为空,实际 = %q", got)
}
})
t.Run("baseUrl 是 http(s) 时仍按 legado 语义回退", func(t *testing.T) {
const base = "https://www.example.com/search?q=x"
a, el := elementOf(t, payload, base)
got, err := a.GetString("$.thumb_url", el, true)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != base {
t.Fatalf("want base fallback %q, got %q", base, got)
}
})
t.Run("规则有取值时原样返回", func(t *testing.T) {
const withCover = `{"data":[{"book_name":"书","thumb_url":"https://img.example.com/c.jpg"}]}`
a, el := elementOf(t, withCover, envelope)
got, err := a.GetString("$.thumb_url", el, true)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != "https://img.example.com/c.jpg" {
t.Fatalf("封面 = %q", got)
}
})
}
@@ -0,0 +1,58 @@
package rule
import "testing"
// 书源普遍用 legado 的内嵌模板拼字段(聚合源的 kind / 最新章节就是这种写法):
//
// kind = "{{$.status}},{{$.score}},{{$.tags}}"
// lastChapter = "{{$.source}} {{$.last_chapter_title}}"
//
// 模板必须在**当前元素**上求值。修复前这里拿整份响应求值,模板一律取空,
// 表现为 kind=",,,"、最新章节为空——搜索结果和换源列表里所有条目长得一模一样。
func TestInnerRuleTemplateEvaluatesAgainstElement(t *testing.T) {
const body = `{"data":[{"book_name":"全球高武","status":"连载","score":"9.2","tags":"都市","source":"svip_酷我","last_chapter_title":"第694章"}]}`
a := NewAnalyzeRule()
a.SetContent(body, "")
els, err := a.GetElements("$.data")
if err != nil {
t.Fatalf("GetElements: %v", err)
}
if len(els) != 1 {
t.Fatalf("want 1 element, got %d", len(els))
}
el := els[0]
got, err := a.GetString("{{$.status}},{{$.score}},{{$.tags}}", el, false)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != "连载,9.2,都市" {
t.Fatalf("kind 模板 = %q, want 连载,9.2,都市", got)
}
got, err = a.GetString("{{$.source}} {{$.last_chapter_title}}", el, false)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != "svip_酷我 第694章" {
t.Fatalf("最新章节模板 = %q, want svip_酷我 第694章", got)
}
// 列表规则(GetStringList)走的是同一套 MakeUpRule,必须同样生效
list, err := a.GetStringList("{{$.status}},{{$.score}}", el, false)
if err != nil {
t.Fatalf("GetStringList: %v", err)
}
if len(list) != 1 || list[0] != "连载,9.2" {
t.Fatalf("列表模板 = %v, want [连载,9.2]", list)
}
// 模板里取不存在的字段应得到空串,而不是报错
got, err = a.GetString("[{{$.not_exist}}]", el, false)
if err != nil {
t.Fatalf("GetString: %v", err)
}
if got != "[]" {
t.Fatalf("缺失字段 = %q, want []", got)
}
}
@@ -0,0 +1,73 @@
package reader
import "testing"
// 同书多源合并:先到的源没有封面/简介时,必须用后面源的有效值补齐。
// 否则「有源带封面」的书在合并成一行后会是空白(legado 一源一行则不会出现)。
func TestMergeSearchResultsFillsMissingFields(t *testing.T) {
hits := []searchHit{
{book: SearchBook{
Name: "全球高武",
Author: "蛮荒帝霸",
CoverURL: "",
Origins: []SearchOrigin{{OriginName: "搜书神器", BookURL: "/a"}},
}},
{book: SearchBook{
Name: "全球高武",
Author: "蛮荒帝霸",
CoverURL: "https://res.chuangke.tv/qk/246/a3/76/2271.jpg",
Intro: "一句话简介",
Kind: "连载",
WordCount: "177.2万字",
LatestChapter: "第694章",
Origins: []SearchOrigin{{OriginName: "svip_AU文学", BookURL: "/b"}},
}},
}
got := mergeSearchResults(hits, "全球高武")
if len(got) != 1 {
t.Fatalf("合并后应只有 1 条,实际 %d", len(got))
}
book := got[0]
if book.CoverURL != "https://res.chuangke.tv/qk/246/a3/76/2271.jpg" {
t.Fatalf("封面未从其它源补齐: %q", book.CoverURL)
}
if book.Intro != "一句话简介" {
t.Fatalf("简介未补齐: %q", book.Intro)
}
if book.Kind != "连载" || book.WordCount != "177.2万字" || book.LatestChapter != "第694章" {
t.Fatalf("字段未补齐: %+v", book)
}
if len(book.Origins) != 2 {
t.Fatalf("源数 = %d, want 2", len(book.Origins))
}
}
// 已经有值的字段不能被后来的源覆盖(先到的更权威,且保证结果稳定)。
func TestMergeSearchResultsKeepsExistingFields(t *testing.T) {
hits := []searchHit{
{book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://a.example.com/1.jpg", Intro: "甲的简介"}},
{book: SearchBook{Name: "书", Author: "作者", CoverURL: "https://b.example.com/2.jpg", Intro: "乙的简介"}},
}
got := mergeSearchResults(hits, "书")
if len(got) != 1 {
t.Fatalf("合并后应只有 1 条,实际 %d", len(got))
}
if got[0].CoverURL != "https://a.example.com/1.jpg" || got[0].Intro != "甲的简介" {
t.Fatalf("不该被后来的源覆盖: %+v", got[0])
}
}
// 无书名的结果直接丢弃,且不会参与合并。
func TestMergeSearchResultsSkipsEmptyName(t *testing.T) {
hits := []searchHit{
{book: SearchBook{Name: "", Author: "作者", CoverURL: "https://a.example.com/1.jpg"}},
{book: SearchBook{Name: "书", Author: "作者"}},
}
got := mergeSearchResults(hits, "书")
if len(got) != 1 || got[0].Name != "书" {
t.Fatalf("结果 = %+v", got)
}
}
@@ -0,0 +1,219 @@
package reader
import (
"fmt"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。
func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string {
t.Helper()
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.ListSources(t.Context())
if err != nil {
t.Fatal(err)
}
for _, s := range srcs {
if s.SourceURL == sourceURL {
return s.ID
}
}
t.Fatalf("导入后找不到书源 %s", sourceURL)
return ""
}
// 换源:来源字段整体换到新源,旧源目录缓存清空,阅读进度保留。
func TestSwitchOriginKeepsProgressAndClearsChapters(t *testing.T) {
oldSrv := e2eServer()
defer oldSrv.Close()
newSrv := e2eServer()
defer newSrv.Close()
svc, repos := newLoginTestService(t)
ctx := t.Context()
oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL)
newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL)
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
SourceID: oldID,
Origin: oldSrv.URL,
OriginName: "旧源",
BookURL: oldSrv.URL + "/book/1",
}, "斗破苍穹", "天蚕土豆", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
// 旧源目录缓存 + 阅读进度
chapters := make([]ChapterInput, 0, 5)
for i := 0; i < 5; i++ {
chapters = append(chapters, ChapterInput{
Index: i,
Title: fmt.Sprintf("第 %d 章", i+1),
URL: fmt.Sprintf("%s/book/1/c%d.html", oldSrv.URL, i),
})
}
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
if err := svc.SaveProgress(ctx, "u1", book.ID, 3, 120, "第 4 章"); err != nil {
t.Fatalf("保存进度失败: %v", err)
}
switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{
SourceID: newID,
Origin: newSrv.URL,
OriginName: "新源",
BookURL: newSrv.URL + "/book/1",
})
if err != nil {
t.Fatalf("换源失败: %v", err)
}
if switched.Origin != newSrv.URL || switched.OriginName != "新源" {
t.Fatalf("来源未切换: origin=%q name=%q", switched.Origin, switched.OriginName)
}
if switched.BookURL != newSrv.URL+"/book/1" {
t.Fatalf("书本地址未切换: %q", switched.BookURL)
}
if !strings.HasPrefix(switched.TocURL, newSrv.URL) {
t.Fatalf("tocUrl 应指向新源: %q", switched.TocURL)
}
if switched.TotalChapterNum != 0 {
t.Fatalf("旧源章数应归零,实际 %d", switched.TotalChapterNum)
}
if switched.DurChapterIndex != 3 || switched.DurChapterPos != 120 || switched.DurChapterTitle != "第 4 章" {
t.Fatalf("阅读进度没保留: %+v", switched)
}
// 旧源章节缓存必须清掉(章节地址只对旧源有效)
left, err := repos.Reader.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(left) != 0 {
t.Fatalf("换源后仍残留 %d 条旧源章节", len(left))
}
// 落库确认,避免只改了内存对象
stored, err := repos.Reader.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if stored.Origin != newSrv.URL || stored.BookURL != newSrv.URL+"/book/1" || stored.DurChapterIndex != 3 {
t.Fatalf("换源结果未落库: %+v", stored)
}
}
// 空封面时用新源的封面补上;已有封面不被覆盖。
func TestSwitchOriginFillsOnlyEmptyCover(t *testing.T) {
oldSrv := e2eServer()
defer oldSrv.Close()
newSrv := e2eServer()
defer newSrv.Close()
svc, repos := newLoginTestService(t)
ctx := t.Context()
oldID := importTestSource(t, svc, e2eSourceJSON(oldSrv.URL), oldSrv.URL)
newID := importTestSource(t, svc, e2eSourceJSON(newSrv.URL), newSrv.URL)
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1",
}, "斗破苍穹", "天蚕土豆", "https://old.example.com/cover.jpg")
if err != nil {
t.Fatal(err)
}
target := SearchOrigin{SourceID: newID, Origin: newSrv.URL, OriginName: "新源", BookURL: newSrv.URL + "/book/1"}
switched, err := svc.SwitchOrigin(ctx, "u1", book.ID, target)
if err != nil {
t.Fatalf("换源失败: %v", err)
}
if switched.CoverURL != "https://old.example.com/cover.jpg" {
t.Fatalf("已有封面不该被覆盖: %q", switched.CoverURL)
}
// 清空封面后换源,应补上新源的封面(e2e 详情页没配封面规则,这里改为一本空封面的书)
switched.CoverURL = ""
if err := repos.Reader.UpdateBook(ctx, switched); err != nil {
t.Fatal(err)
}
back, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{
SourceID: oldID, Origin: oldSrv.URL, OriginName: "旧源", BookURL: oldSrv.URL + "/book/1",
})
if err != nil {
t.Fatalf("换回旧源失败: %v", err)
}
// e2e 书源没有 coverUrl 规则,所以这里只断言流程走通且不报错
if back.Origin != oldSrv.URL {
t.Fatalf("换回失败: %+v", back)
}
}
// 换源的各种拒绝场景。
func TestSwitchOriginRejects(t *testing.T) {
srv := e2eServer()
defer srv.Close()
svc, repos := newLoginTestService(t)
ctx := t.Context()
srcID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1",
}, "斗破苍穹", "天蚕土豆", "")
if err != nil {
t.Fatal(err)
}
valid := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/2"}
t.Run("他人书架", func(t *testing.T) {
if _, err := svc.SwitchOrigin(ctx, "u2", book.ID, valid); err == nil || !strings.Contains(err.Error(), "无权") {
t.Fatalf("err = %v, want 无权操作他人书架", err)
}
})
t.Run("本地书没有书源", func(t *testing.T) {
local := &model.ReaderBook{
UserID: "u3",
Name: "本地书",
LocalPath: "abc.txt",
BookURL: "local://abc",
}
if err := repos.Reader.CreateBook(ctx, local); err != nil {
t.Fatal(err)
}
if _, err := svc.SwitchOrigin(ctx, "u3", local.ID, valid); err == nil || !strings.Contains(err.Error(), "本地导入") {
t.Fatalf("err = %v, want 本地书不可换源", err)
}
})
t.Run("缺少目标书本地址", func(t *testing.T) {
if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, SearchOrigin{Origin: srv.URL}); err == nil {
t.Fatal("缺少 book_url 时应报错")
}
})
t.Run("书源不存在", func(t *testing.T) {
bad := SearchOrigin{Origin: "https://nope.example.com", BookURL: "https://nope.example.com/book/1"}
if _, err := svc.SwitchOrigin(ctx, "u1", book.ID, bad); err == nil {
t.Fatal("书源不存在时应报错")
}
})
t.Run("同一源重复换源视为成功", func(t *testing.T) {
same := SearchOrigin{SourceID: srcID, Origin: srv.URL, OriginName: "测试源", BookURL: srv.URL + "/book/1"}
got, err := svc.SwitchOrigin(ctx, "u1", book.ID, same)
if err != nil {
t.Fatalf("重复换源不该报错: %v", err)
}
if got.BookURL != srv.URL+"/book/1" {
t.Fatalf("书本地址被改动: %q", got.BookURL)
}
})
}