This commit is contained in:
truewhile
2026-10-04 11:46:17 +08:00
parent dacfec7492
commit 6656f9c398
13 changed files with 474 additions and 77 deletions
+5 -5
View File
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员", nil)
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
+9 -2
View File
@@ -766,7 +766,14 @@ type searchHit struct {
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
//
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
if page < 1 {
page = 1
}
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, nil, err
@@ -790,7 +797,7 @@ func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []stri
g.Go(func() error {
gctxSrc, cancel := context.WithTimeout(gctx, perSourceTimeout)
defer cancel()
books, err := s.searchInSource(gctxSrc, &src, nil, key, 1)
books, err := s.searchInSource(gctxSrc, &src, nil, key, page)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -0,0 +1,98 @@
package reader
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// 本文件:搜索分页。
//
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
func pagedSearchSourceJSON(serverURL string) string {
src := map[string]any{
"bookSourceUrl": serverURL,
"bookSourceName": "分页源",
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
"ruleSearch": map[string]any{
"bookList": "$.data",
"name": "$.book_name",
"author": "$.author",
"bookUrl": "$.book_url",
},
}
out, _ := json.Marshal(src)
return string(out)
}
func TestSearchUsesRequestedPage(t *testing.T) {
var gotPage string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPage = r.URL.Query().Get("page")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
payload := map[string]any{"data": []map[string]string{{
"book_name": fmt.Sprintf("第%s页的书", gotPage),
"author": "作者",
"book_url": "/book/" + gotPage,
}}}
_ = json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
for _, tc := range []struct {
in int
want string
}{
{1, "1"},
{2, "2"},
{5, "5"},
{0, "1"}, // 非法页码退回第 1 页
{-3, "1"}, // 负数同理
} {
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
if err != nil {
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
}
if len(skipped) > 0 {
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
}
if len(books) != 1 {
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
}
if gotPage != tc.want {
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
}
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
}
}
}
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
func TestSearchWithoutPagePlaceholderStillWorks(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":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
books, _, err := svc.Search(t.Context(), "书", nil, 3)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
if len(books) != 1 || books[0].Name != "单页书" {
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
}
}
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out))
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
t.Fatalf("搜索失败: %v", err)
}
if gotQuery != "宠魅" {
+3 -3
View File
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 指定单个源:只应搜到该源的结果
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
if err != nil {
t.Fatal(err)
}
@@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 空范围:默认全部启用书源,两个源都应命中
books, _, err = svc.Search(t.Context(), "书", nil)
books, _, err = svc.Search(t.Context(), "书", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
if err != nil {
t.Fatal(err)
}