diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go
index af16ce1..f0a794b 100644
--- a/internal/handler/reader_routes.go
+++ b/internal/handler/reader_routes.go
@@ -408,6 +408,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
+ // 顺手修复书架里被上游限流写坏的封面(存成接口地址的那种),先按原始
+ // 地址写回,再做下发时的解密代理改写。
+ userID := c.GetString(middleware.CtxUserID)
+ svc.Reader.RepairShelfCover(c.Request.Context(), userID, c.Query("source_url"), c.Query("book_url"), info.CoverURL)
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
diff --git a/internal/service/reader/cover_repair_test.go b/internal/service/reader/cover_repair_test.go
new file mode 100644
index 0000000..3789c0c
--- /dev/null
+++ b/internal/service/reader/cover_repair_test.go
@@ -0,0 +1,69 @@
+package reader
+
+import (
+ "testing"
+
+ "github.com/truewhile/MeBox/internal/model"
+)
+
+// 上游限流时详情解析不到封面,规则会把页面地址当封面回退。这类记录被存进书架后
+// 会一直显示破图;打开一次详情页应当用真实封面把它修好。
+func TestRepairShelfCoverReplacesFallbackURL(t *testing.T) {
+ svc, _ := newLoginTestService(t)
+ ctx := t.Context()
+
+ const origin = "https://api.example.com/book"
+ const bookURL = "https://api.example.com/book?bookId=1"
+ const cover = "https://img.example.com/c.jpg"
+
+ book := &model.ReaderBook{
+ UserID: "u1",
+ Origin: origin,
+ BookURL: bookURL,
+ Name: "测试书",
+ CoverURL: bookURL, // 规则取空后回退的产物
+ }
+ if err := svc.repo.CreateBook(ctx, book); err != nil {
+ t.Fatal(err)
+ }
+
+ svc.RepairShelfCover(ctx, "u1", origin, bookURL, cover)
+
+ stored, err := svc.repo.GetBook(ctx, book.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.CoverURL != cover {
+ t.Fatalf("封面应修为 %q,实际 %q", cover, stored.CoverURL)
+ }
+}
+
+// 已经有有效封面的书不能被动覆盖(可能是换源得到的、或用户自己挑的)。
+func TestRepairShelfCoverKeepsValidCover(t *testing.T) {
+ svc, _ := newLoginTestService(t)
+ ctx := t.Context()
+
+ const origin = "https://api.example.com/book"
+ const bookURL = "https://api.example.com/book?bookId=1"
+
+ book := &model.ReaderBook{
+ UserID: "u1",
+ Origin: origin,
+ BookURL: bookURL,
+ Name: "测试书",
+ CoverURL: "https://img.example.com/old.jpg",
+ }
+ if err := svc.repo.CreateBook(ctx, book); err != nil {
+ t.Fatal(err)
+ }
+
+ svc.RepairShelfCover(ctx, "u1", origin, bookURL, "https://img.example.com/new.jpg")
+
+ stored, err := svc.repo.GetBook(ctx, book.ID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if stored.CoverURL != "https://img.example.com/old.jpg" {
+ t.Fatalf("有效封面不应被覆盖,实际 %q", stored.CoverURL)
+ }
+}
diff --git a/internal/service/reader/cover_url_test.go b/internal/service/reader/cover_url_test.go
index a76a90b..5418e3c 100644
--- a/internal/service/reader/cover_url_test.go
+++ b/internal/service/reader/cover_url_test.go
@@ -6,32 +6,48 @@ import "testing"
// normalizeCoverURL 必须拦住这类非图片地址,避免前端 显示破图。
func TestNormalizeCoverURL(t *testing.T) {
cases := []struct {
- name string
- in string
- want string
+ name string
+ in string
+ baseURL 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"},
+ {"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)", "", ""},
+ {
+ // 详情规则取不到 $.meta.cover 时按 legado 语义回退成页面地址,
+ // 这个接口地址不是图片,必须丢掉。
+ "fallback to page url",
+ "https://api.example.com/book?bookId=1",
+ "https://api.example.com/book?bookId=1",
+ "",
+ },
+ {
+ "real cover with page url base",
+ "https://img.example.com/a.jpg",
+ "https://api.example.com/book?bookId=1",
+ "https://img.example.com/a.jpg",
},
- {"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)
+ if got := normalizeCoverURL(tc.in, tc.baseURL); got != tc.want {
+ t.Fatalf("normalizeCoverURL(%q, %q) = %q, want %q", tc.in, tc.baseURL, got, tc.want)
}
})
}
diff --git a/internal/service/reader/rate_limit.go b/internal/service/reader/rate_limit.go
index f0585f9..951ff9f 100644
--- a/internal/service/reader/rate_limit.go
+++ b/internal/service/reader/rate_limit.go
@@ -80,6 +80,10 @@ func parseConcurrentRate(rate string) time.Duration {
const (
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
maxRequestAttempts = 5
+ // implicitRetryAttempts 书源没声明 retry 时,GET 请求在可重试状态码上的默认尝试次数。
+ // 实测这类聚合源的单次 429 概率接近一半,两次尝试仍有约两成失败,三次降到一成左右;
+ // 再往上加只会让被限流的接口更难恢复。退避总共不到 1 秒,详情/目录请求扛得住。
+ implicitRetryAttempts = 3
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
requestRetryDelay = 300 * time.Millisecond
)
@@ -98,6 +102,20 @@ func retryableStatus(code int) bool {
}
}
+// implicitRetryableStatus 书源没声明 retry 时,值得自动补一次的失败。
+//
+// 只覆盖「上游临时顶不住」的情形:429 是限流/并发上限,502/503/504 是网关或
+// 实例暂时不可用,歇一轮基本会恢复。403(风控)重试同一份请求多半还是 403,
+// 500 可能是源站自身 bug(确定性失败),都不在这里浪费一次往返。
+func implicitRetryableStatus(code int) bool {
+ switch code {
+ case 429, 502, 503, 504:
+ return true
+ default:
+ return false
+ }
+}
+
// mediaRetryableStatus 媒体拉取专用的可重试判定。
//
// 与规则请求不同,CDN 直链的 410 表示「对象已下线」,与 403/429 一样属于
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index a8f15f3..eb7807f 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -425,12 +425,25 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
// retry:URL 选项声明的重试次数(对应 legado AnalyzeUrl 的同名字段)。
// 只重试可恢复的失败:网络错误、5xx、403/429(防盗链/限流)。确定性 4xx 不重试。
attempts := 1
- if req.Retry != nil && *req.Retry > 0 {
+ declaredRetry := req.Retry != nil && *req.Retry > 0
+ if declaredRetry {
attempts += *req.Retry
if attempts > maxRequestAttempts {
attempts = maxRequestAttempts
}
}
+ // 书源没声明 retry 时,GET 也对上游瞬时失败(429/5xx)自动补一次。
+ // 很多聚合源跑在免费/低配后端上,限流是常态(懒人听书源实测约四成请求返回
+ // HTTP 429),而 429 的响应体根本不是书源数据,交给规则只会解析出空目录、
+ // 空详情。POST 可能有副作用,仍然只按书源声明重试。
+ if !declaredRetry && (req.Method == "" || req.Method == http.MethodGet) {
+ attempts = implicitRetryAttempts
+ }
+ // 自动补的一次只认「上游临时顶不住」的状态码,见 implicitRetryableStatus。
+ retryAllowed := retryableStatus
+ if !declaredRetry {
+ retryAllowed = implicitRetryableStatus
+ }
var (
resp *http.Response
data []byte
@@ -464,7 +477,7 @@ func (s *ReaderService) executeWithState(ctx context.Context, req *rule.Request,
}
return "", "", resp.StatusCode, err
}
- if attempt < attempts && retryableStatus(resp.StatusCode) {
+ if attempt < attempts && retryAllowed(resp.StatusCode) {
if !sleepWithContext(ctx, requestRetryDelay*time.Duration(attempt)) {
return "", "", resp.StatusCode, nil
}
@@ -536,6 +549,25 @@ func charsetFromContentType(ct string) string {
return strings.Trim(cs, "\"")
}
+// upstreamStatusError 把书源接口的失败状态码翻译成可读错误。
+//
+// 上游限流/过载时返回的响应体不是书源数据:交给规则解析只会得到空目录、空详情,
+// 页面就表现成「目录 0 章」而没有任何报错。这里让调用方拿到明确错误,
+// 前端才能提示重试,而不是把「抓不到」当成「这本书没有章节」。
+func upstreamStatusError(code int) error {
+ if code < 400 {
+ return nil
+ }
+ switch code {
+ case http.StatusTooManyRequests:
+ return fmt.Errorf("书源接口限流(HTTP 429),请稍后重试")
+ case http.StatusForbidden:
+ return fmt.Errorf("书源接口拒绝访问(HTTP 403),可能触发了风控")
+ default:
+ return fmt.Errorf("书源接口返回 HTTP %d", code)
+ }
+}
+
// ─── 规则执行辅助 ───────────────────────────────────────────────────────────
// sourceSession 是一次书源操作的执行上下文:把书源记录、解析结构与
@@ -1131,10 +1163,13 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
- body, finalURL, _, err := sess.fetch(req)
+ body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
+ if err := upstreamStatusError(code); err != nil {
+ return nil, err
+ }
ar := sess.newAnalyzer(key, page, body, finalURL)
elements, err := ar.GetElements(SPtr(sr.BookList))
@@ -1164,7 +1199,7 @@ func (s *ReaderService) searchInSource(ctx context.Context, src *model.ReaderBoo
WordCount: wordCount,
LatestChapter: lastChapter,
Intro: intro,
- CoverURL: normalizeCoverURL(cover),
+ CoverURL: normalizeCoverURL(cover, finalURL),
BookURL: bookURL,
Origins: []SearchOrigin{{
SourceID: src.ID,
@@ -1221,11 +1256,20 @@ func firstNonEmpty(vals ...string) string {
// 于是封面上会落一串 data: 文本,
按 text/plain 处理必然破图。
// 允许的相对地址以 "/" 开头,用于应用自身生成的本地书封面
// (/api/reader/local/asset?...)。
-func normalizeCoverURL(raw string) string {
+//
+// baseURL 是规则解析时的页面地址(详情页就是书本地址)。取值为空时规则会原样
+// 回退成它,回退结果同样不是图片:比如懒人听书源被上游限流(HTTP 429)时,
+// 详情 JSON 里没有 meta.cover,封面就会变成 .../lrts-book?bookId=xxx 这个接口地址。
+// 这类值必须一并丢掉,否则会被存进书架,之后一直显示破图。
+func normalizeCoverURL(raw, baseURL string) string {
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
+ // 规则取空后回退成页面地址的产物:不是图片,也不是用户要的封面。
+ if base := strings.TrimSpace(baseURL); base != "" && u == base {
+ return ""
+ }
lower := strings.ToLower(u)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
@@ -1282,10 +1326,13 @@ func (s *ReaderService) getBookInfoFrom(ctx context.Context, src *model.ReaderBo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
- body, finalURL, _, err := sess.fetch(req)
+ body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
+ if err := upstreamStatusError(code); err != nil {
+ return nil, err
+ }
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, book, "", 0)
@@ -1319,7 +1366,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 = normalizeCoverURL(v)
+ info.CoverURL = normalizeCoverURL(v, finalURL)
}
if v, err := ar.GetString(SPtr(bir.TocURL), nil, true); err == nil && v != "" {
info.TocURL = v
@@ -1569,6 +1616,37 @@ func (s *ReaderService) persistBookTocURL(ctx context.Context, userID, origin, b
}
}
+// RepairShelfCover 用详情页刚解析出的封面修复书架记录。
+//
+// 只修「明显坏掉」的旧记录:封面为空,或等于书本地址/书源地址(规则取空后回退
+// baseUrl 的产物)。上游限流(HTTP 429)时详情里没有封面,旧版本会把接口地址
+// 存进书架,之后一直显示破图;打开一次详情页就能自愈。已有有效封面的记录不动,
+// 避免覆盖换源或用户数据。
+func (s *ReaderService) RepairShelfCover(ctx context.Context, userID, origin, bookURL, cover string) {
+ if s.repo == nil || userID == "" || origin == "" || bookURL == "" {
+ return
+ }
+ // 书架记录里的 BookURL 与调用方传入的一致(可能带 ",{...}" 选项段),
+ // 查找用原值;只在校验封面是否为「回退地址」时用去掉选项段的形态。
+ pageURL := tocBookTarget(bookURL)
+ cover = normalizeCoverURL(cover, pageURL)
+ if cover == "" {
+ return
+ }
+ book, err := s.repo.FindBookByURL(ctx, userID, origin, bookURL)
+ if err != nil || book == nil {
+ return
+ }
+ current := strings.TrimSpace(book.CoverURL)
+ if current != "" && current != pageURL && current != strings.TrimSpace(origin) {
+ return
+ }
+ book.CoverURL = cover
+ if err := s.repo.UpdateBook(ctx, book); err != nil && s.log != nil {
+ s.log.Warn("reader: 修复书架封面失败", zap.String("book", book.ID), zap.Error(err))
+ }
+}
+
// applyTocMeta 把目录阶段得到的信息写回书架记录:
// - 书源在规则 JS 里声明的书籍类型(declared >= 0 时);
// - 末章标题,以及末章变化时刷新的「最近更新」时间(对应 legado Book.latestChapterTime)。
@@ -1695,10 +1773,13 @@ func (s *ReaderService) fetchTocPage(ctx context.Context, sess *sourceSession, b
return nil, nil, -1, req.Unsupported
}
sess.applyHeaders(req, runner)
- body, finalURL, _, err := sess.fetch(req)
+ body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, nil, -1, err
}
+ if err := upstreamStatusError(code); err != nil {
+ return nil, nil, -1, err
+ }
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, nil, "", 0)
@@ -1821,7 +1902,7 @@ func (s *ReaderService) ProxyCoverURL(id, rawURL string) string {
//
// id 为空时按书源 URL 签(搜索结果);传入书籍 ID 时按书签。
func (s *ReaderService) RewriteBookCover(ctx context.Context, id, origin, coverURL string) string {
- cover := normalizeCoverURL(coverURL)
+ cover := normalizeCoverURL(coverURL, "")
if cover == "" || !strings.HasPrefix(cover, "http") {
return cover
}
@@ -2082,10 +2163,13 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
return nil, req.Unsupported
}
sess.applyHeaders(req, runner)
- body, finalURL, _, err := sess.fetch(req)
+ body, finalURL, code, err := sess.fetch(req)
if err != nil {
return nil, err
}
+ if err := upstreamStatusError(code); err != nil {
+ return nil, err
+ }
lastFinalURL = finalURL
ar := sess.newAnalyzer("", 0, body, finalURL)
sess.applyBookContext(ar, bookURL, sess.book, "", 0)
@@ -2550,7 +2634,7 @@ func (s *ReaderService) AddBook(ctx context.Context, userID string, origin Searc
BookURL: origin.BookURL,
Name: name,
Author: author,
- CoverURL: normalizeCoverURL(coverURL),
+ CoverURL: normalizeCoverURL(coverURL, origin.BookURL),
Type: origin.OriginType,
}
if err := s.repo.CreateBook(ctx, book); err != nil {
@@ -2611,7 +2695,7 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
if info != nil {
book.TocURL = strings.TrimSpace(info.TocURL)
if book.CoverURL == "" {
- book.CoverURL = normalizeCoverURL(info.CoverURL)
+ book.CoverURL = normalizeCoverURL(info.CoverURL, target)
}
if info.LatestChapter != "" {
book.LatestChapterTitle = info.LatestChapter
diff --git a/internal/service/reader/upstream_retry_test.go b/internal/service/reader/upstream_retry_test.go
new file mode 100644
index 0000000..3c63c29
--- /dev/null
+++ b/internal/service/reader/upstream_retry_test.go
@@ -0,0 +1,82 @@
+package reader
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// 上游限流(HTTP 429)在免费/低配后端上是常态。书源没声明 retry 时,
+// GET 也会自动补一次:第一次 429、第二次正常时,详情必须能拿到数据。
+func TestGetBookInfoRetriesUpstream429(t *testing.T) {
+ upstream := e2eServer()
+ defer upstream.Close()
+
+ var mu sync.Mutex
+ hits := map[string]int{}
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ hits[r.URL.Path]++
+ n := hits[r.URL.Path]
+ mu.Unlock()
+ if n == 1 {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
+ return
+ }
+ resp, err := http.Get(upstream.URL + r.URL.RequestURI())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ defer resp.Body.Close()
+ w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
+ w.WriteHeader(resp.StatusCode)
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := resp.Body.Read(buf)
+ if n > 0 {
+ _, _ = w.Write(buf[:n])
+ }
+ if err != nil {
+ return
+ }
+ }
+ }))
+ defer srv.Close()
+
+ svc, _ := newLoginTestService(t)
+ sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
+
+ info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
+ if err != nil {
+ t.Fatalf("第一次 429、重试后应成功: %v", err)
+ }
+ if info.Name == "" {
+ t.Fatalf("重试后详情应有书名,实际为空")
+ }
+}
+
+// 持续限流时必须报错,而不是把 429 的响应体解析成「空目录 / 0 章」。
+func TestGetTocReportsUpstream429(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ _, _ = w.Write([]byte(`{"code":"projectConcurrencyLimitExceeded","message":"当前访问人数较多"}`))
+ }))
+ defer srv.Close()
+
+ svc, _ := newLoginTestService(t)
+ sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
+
+ _, err := svc.GetToc(t.Context(), "u1", sourceID, srv.URL, srv.URL+"/book/1", srv.URL+"/book/1")
+ if err == nil {
+ t.Fatalf("持续 429 时应返回错误,而不是空目录")
+ }
+ if !strings.Contains(err.Error(), "429") {
+ t.Fatalf("错误信息应包含 429,实际: %v", err)
+ }
+}
diff --git a/web/src/pages/reader/ReaderBookPage.tsx b/web/src/pages/reader/ReaderBookPage.tsx
index 83e92f6..4d7cc66 100644
--- a/web/src/pages/reader/ReaderBookPage.tsx
+++ b/web/src/pages/reader/ReaderBookPage.tsx
@@ -23,6 +23,9 @@ export default function ReaderBookPage() {
const [info, setInfo] = useState
- 最新:{info?.latest_chapter || '加载中…'} + 最新:{info?.latest_chapter || (info ? '—' : '加载中…')}
书源:{originName || sourceURL}
{(info?.intro || infoError) && ( @@ -264,7 +302,15 @@ export default function ReaderBookPage() {{infoError || info?.intro}
- {!infoError && ( + {infoError ? ( + + ) : (