diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index 87b601b..b3af624 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -33,7 +33,7 @@ - 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。 - 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。 - 显示项开关:未读章数徽标、更新时间。 -- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。 +- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。 - 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。 **书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 609b5a3..94595ec 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -2074,11 +2074,24 @@ func (s *ReaderService) WarmUpBookChapters(ctx context.Context, userID string, b // TocRefreshResult 「更新目录」的结果汇总(对应 legado 更新目录后的提示)。 type TocRefreshResult struct { - Total int `json:"total"` // 参与刷新的网络书籍数 - Updated int `json:"updated"` // 章数变多的书(有新章节) - Failed int `json:"failed"` // 抓取或写入失败的书 + Total int `json:"total"` // 参与刷新的网络书籍数 + Updated int `json:"updated"` // 章数变多的书(有新章节) + Unchanged int `json:"unchanged"` // 抓取成功但没有新章节的书(已是最新) + Failed int `json:"failed"` // 抓取或写入失败的书 } +// tocRefreshStatus 单本书目录刷新的结果。 +type tocRefreshStatus int + +const ( + // tocRefreshUnchanged 抓取并覆盖成功,只是章数没变多(完结书、站点暂无更新)。 + tocRefreshUnchanged tocRefreshStatus = iota + // tocRefreshUpdated 抓取成功且章数变多,有新章节。 + tocRefreshUpdated + // tocRefreshFailed 抓不到目录、抓到空目录或写不进缓存。 + tocRefreshFailed +) + // refreshTocConcurrency 「更新目录」的并发度。书源站点多有限流,不宜过大。 const refreshTocConcurrency = 4 @@ -2087,6 +2100,8 @@ const refreshTocConcurrency = 4 // 逐本重新抓目录、覆盖章节缓存;末章变化时由 GetToc → applyTocMeta 刷新 // latest_chapter_time。本地书籍与没有书源信息的书籍跳过。 // 并发受限,单本失败只计数、不中断整体;等待全部结束后返回汇总。 +// 「抓到但没新章节」与「抓失败」分开计数:前者是正常结果(完结书天天检查都没有新章节), +// 混在一起会让人误以为书源坏了。 func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*TocRefreshResult, error) { books, err := s.repo.ListBooks(ctx, userID) if err != nil { @@ -2118,43 +2133,67 @@ func (s *ReaderService) RefreshBooksToc(ctx context.Context, userID string) (*To defer func() { <-sem }() bookCtx, cancel := context.WithTimeout(ctx, perSourceTimeout) defer cancel() - if s.refreshBookToc(bookCtx, userID, b) { - mu.Lock() - res.Updated++ - mu.Unlock() - return - } + status := s.refreshBookToc(bookCtx, userID, b) mu.Lock() - res.Failed++ - mu.Unlock() + defer mu.Unlock() + switch status { + case tocRefreshUpdated: + res.Updated++ + case tocRefreshUnchanged: + res.Unchanged++ + default: + res.Failed++ + } }(b) } wg.Wait() return res, nil } -// refreshBookToc 刷新单本书的目录,返回是否检测到新章节。 -func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) bool { +// refreshBookToc 刷新单本书的目录,返回本次刷新的结果。 +func (s *ReaderService) refreshBookToc(ctx context.Context, userID string, book model.ReaderBook) tocRefreshStatus { before, _ := s.repo.CountChaptersByBook(ctx, []string{book.ID}) chapters, err := s.GetToc(ctx, userID, "", book.Origin, book.BookURL, book.TocURL) - if err != nil || len(chapters) == 0 { - if err != nil && s.log != nil { - s.log.Debug("reader: 更新目录失败", zap.String("book", book.ID), zap.Error(err)) - } - return false + if err != nil { + s.logTocRefreshFailure("reader: 更新目录失败", book, err) + return tocRefreshFailed + } + // 书源规则不报错但一章都没解析出来,多是站点返回了风控/报错响应体(规则解析成空列表)。 + // 这种静默失败要当失败处理并留下日志,否则书架上只会看到「已是最新」。 + if len(chapters) == 0 { + s.logTocRefreshFailure("reader: 更新目录为空", book, nil) + return tocRefreshFailed } inputs := make([]ChapterInput, 0, len(chapters)) for _, ch := range chapters { inputs = append(inputs, ChapterInput{Index: ch.Index, Title: ch.Title, URL: ch.URL, IsVolume: ch.IsVolume}) } if err := s.SaveChapters(ctx, book.ID, inputs); err != nil { - if s.log != nil { - s.log.Warn("reader: 更新目录写入失败", zap.String("book", book.ID), zap.Error(err)) - } - return false + s.logTocRefreshFailure("reader: 更新目录写入失败", book, err) + return tocRefreshFailed } - beforeCount := before[book.ID] - return beforeCount > 0 && len(chapters) > beforeCount + // 首次抓目录(书架还没有缓存)没有可比基准,按「已是最新」计,不虚报新章节。 + if beforeCount := before[book.ID]; beforeCount > 0 && len(chapters) > beforeCount { + return tocRefreshUpdated + } + return tocRefreshUnchanged +} + +// logTocRefreshFailure 记录目录刷新失败。用 Warn 而不是 Debug:这类失败基本都来自 +// 书源站点(风控、改版、限流),排查时只能靠日志,info 级别下 Debug 是看不到的。 +func (s *ReaderService) logTocRefreshFailure(msg string, book model.ReaderBook, err error) { + if s.log == nil { + return + } + fields := []zap.Field{ + zap.String("book", book.ID), + zap.String("name", book.Name), + zap.String("origin", book.OriginName), + } + if err != nil { + fields = append(fields, zap.Error(err)) + } + s.log.Warn(msg, fields...) } // ListReplaceRules 用户替换规则列表。 diff --git a/internal/service/reader/refresh_toc_test.go b/internal/service/reader/refresh_toc_test.go new file mode 100644 index 0000000..2e05577 --- /dev/null +++ b/internal/service/reader/refresh_toc_test.go @@ -0,0 +1,134 @@ +package reader + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 本文件:「更新目录」的结果分档回归测试。 +// +// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节, +// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档: +// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。 + +// refreshTocForTest 跑一次「更新目录」并返回汇总。 +func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult { + t.Helper() + res, err := svc.RefreshBooksToc(ctx, "u1") + if err != nil { + t.Fatalf("更新目录失败: %v", err) + } + return res +} + +// newRefreshTocServer 建一个目录页内容可切换的测试源站点。 +func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + switch { + case strings.HasPrefix(r.URL.Path, "/book/1/toc"): + (*tocHandler)(w, r) + case strings.HasPrefix(r.URL.Path, "/book/"): + _, _ = w.Write([]byte(e2eBookInfoHTML)) + default: + http.NotFound(w, r) + } + })) +} + +func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) { + tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(e2eTocHTML)) + }) + srv := newRefreshTocServer(&tocHandler) + defer srv.Close() + + svc, _ := newLoginTestService(t) + ctx := t.Context() + importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL) + + book := &model.ReaderBook{ + UserID: "u1", + Origin: srv.URL, + OriginName: "测试源", + BookURL: srv.URL + "/book/1", + TocURL: srv.URL + "/book/1/toc.html", + Name: "斗破苍穹", + } + if err := svc.repo.CreateBook(ctx, book); err != nil { + t.Fatalf("创建书籍失败: %v", err) + } + + // 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。 + res := refreshTocForTest(t, svc, ctx) + if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 { + t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res) + } + chapters, err := svc.ListChapters(ctx, book.ID) + if err != nil { + t.Fatal(err) + } + if len(chapters) != 2 { + t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters)) + } + + // 2) 站点多出一章:这才算「有新章节」。 + tocHandler = func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(strings.Replace( + e2eTocHTML, "", `