mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-12 08:16:37 +08:00
优化
This commit is contained in:
@@ -269,7 +269,7 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc
|
||||
if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" {
|
||||
return headers
|
||||
}
|
||||
if src, err := s.repo.GetSourceByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
|
||||
if src, err := s.repo.GetSourceAnyByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
|
||||
var extra map[string]any
|
||||
if json.Unmarshal([]byte(src.Header), &extra) == nil {
|
||||
for k, v := range extra {
|
||||
|
||||
@@ -450,7 +450,7 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL st
|
||||
}
|
||||
// 书源级请求头(含 Referer / UA),与阅读请求保持一致
|
||||
if s.repo != nil {
|
||||
if src, findErr := s.repo.GetSourceByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(src.Header), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
@@ -557,7 +557,7 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
}
|
||||
}
|
||||
if s.repo != nil {
|
||||
if src, findErr := s.repo.GetSourceByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
|
||||
var extra map[string]any
|
||||
if json.Unmarshal([]byte(src.Header), &extra) == nil {
|
||||
for k, v := range extra {
|
||||
|
||||
@@ -444,7 +444,7 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
|
||||
return nil, fmt.Errorf("书籍不存在或已移出书架")
|
||||
}
|
||||
sourceID := ""
|
||||
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
|
||||
if src, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && src != nil {
|
||||
sourceID = src.ID
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestDecodeImageBytesRunsRuleJS(t *testing.T) {
|
||||
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x55; } out"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
rule := svc.ImageDecodeRule(ctx, book)
|
||||
@@ -64,7 +64,7 @@ func TestDecodeImageBytesFallsBackOnBadRule(t *testing.T) {
|
||||
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "throw new Error('bad rule')"}`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
data := []byte("still-an-image")
|
||||
@@ -87,7 +87,7 @@ func TestDecodeCoverBytes(t *testing.T) {
|
||||
`"bookSourceType": 0, "coverDecodeJs": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x33; } out",`,
|
||||
1,
|
||||
)
|
||||
if _, err := svc.ImportSources(ctx, raw); err != nil {
|
||||
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if !svc.SourceHasCoverDecode(srv.URL) {
|
||||
|
||||
@@ -73,7 +73,7 @@ type LoginResult struct {
|
||||
|
||||
// GetSourceLogin 返回书源登录界面描述与当前登录状态。
|
||||
func (s *ReaderService) GetSourceLogin(ctx context.Context, userID, sourceID string) (*SourceLoginInfo, error) {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -142,7 +142,7 @@ func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource,
|
||||
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
|
||||
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
|
||||
func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, action string, fields map[string]string) (*LoginResult, error) {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -206,11 +206,11 @@ func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, ac
|
||||
|
||||
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
|
||||
// 保存后书源 JS 的 getVariable() 即可读到。
|
||||
func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variable string) error {
|
||||
func (s *ReaderService) SetSourceVariable(ctx context.Context, userID, sourceID, variable string) error {
|
||||
if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
|
||||
return fmt.Errorf("变量必须是合法 JSON")
|
||||
}
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -222,8 +222,8 @@ func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variabl
|
||||
}
|
||||
|
||||
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
|
||||
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, userID, sourceID string, fields map[string]string) error {
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,8 +239,8 @@ func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string,
|
||||
}
|
||||
|
||||
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
|
||||
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
func (s *ReaderService) ClearSourceLogin(ctx context.Context, userID, sourceID string) error {
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
|
||||
|
||||
// readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。
|
||||
const readerTestUserID = "test-user"
|
||||
const readerTestUserID = "u1"
|
||||
|
||||
// loginTestServer 模拟一个需要登录的书源站点:
|
||||
// - POST /login_api 校验账号密码并下发会话 Cookie
|
||||
@@ -129,10 +129,10 @@ func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
|
||||
// prepareLoginSource 导入测试书源并返回其 ID。
|
||||
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil {
|
||||
if _, err := svc.ImportSources(t.Context(), "u1", sourceJSON); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srcs, err := svc.ListSources(t.Context())
|
||||
srcs, err := svc.ListSources(t.Context(), "u1")
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("导入后应能读到书源: %v", err)
|
||||
}
|
||||
@@ -151,14 +151,14 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "u1", "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
}
|
||||
|
||||
// ── 登录 ──
|
||||
res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
|
||||
res, err := svc.RunLoginAction(ctx, "u1", sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -176,7 +176,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
|
||||
// ── 登录态应落库(换一个 service 实例仍可读到)──
|
||||
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
|
||||
info, err := svc2.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
info, err := svc2.GetSourceLogin(ctx, "u1", sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
|
||||
books, skipped, err := svc.Search(ctx, "u1", "会员", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -200,17 +200,17 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登出:Cookie 清除,搜索重新未登录 ──
|
||||
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil {
|
||||
if err := svc.ClearSourceLogin(ctx, "u1", sourceID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err = svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
info, err = svc.GetSourceLogin(ctx, "u1", sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "u1", "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -224,7 +224,7 @@ func TestSourceLoginWrongPassword(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "", map[string]string{
|
||||
res, err := svc.RunLoginAction(t.Context(), "u1", sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "wrong",
|
||||
})
|
||||
if err != nil {
|
||||
@@ -248,7 +248,7 @@ func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
info, err := svc.GetSourceLogin(t.Context(), readerTestUserID, sourceID)
|
||||
info, err := svc.GetSourceLogin(t.Context(), "u1", sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -280,7 +280,7 @@ func TestSourceStateEncryptedAtRest(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if _, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
|
||||
if _, err := svc.RunLoginAction(ctx, "u1", sourceID, "", map[string]string{
|
||||
"邮箱": "user@example.com", "密码": "pw123456",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -313,10 +313,10 @@ func TestSourceVariableRoundTrip(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
|
||||
if err := svc.SetSourceVariable(ctx, "u1", sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
|
||||
info, err := svc.GetSourceLogin(ctx, "u1", sourceID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -329,7 +329,7 @@ func TestSourceVariableRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
// 非法 JSON 应被拒绝
|
||||
if err := svc.SetSourceVariable(ctx, sourceID, "not-json"); err == nil {
|
||||
if err := svc.SetSourceVariable(ctx, "u1", sourceID, "not-json"); err == nil {
|
||||
t.Fatal("非法 JSON 变量应被拒绝")
|
||||
}
|
||||
}
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "u1", "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -386,10 +386,10 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
defer srv2.Close()
|
||||
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
|
||||
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
if _, err := svc.ImportSources(ctx, "u1", srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "u1", "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -123,7 +123,7 @@ func (s *ReaderService) SourceHasCoverDecode(origin string) bool {
|
||||
|
||||
// ImportSources 导入书源:支持 JSON 数组 / 单对象 / Base64 / 网络URL。
|
||||
// 返回导入数量。
|
||||
func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, error) {
|
||||
func (s *ReaderService) ImportSources(ctx context.Context, userID, text string) (int, error) {
|
||||
text = strings.TrimSpace(text)
|
||||
// 去 UTF-8 BOM(Windows 记事本导出的书源文件常见),否则 URL 检测和 JSON 解析都会失败
|
||||
text = strings.TrimPrefix(text, "\uFEFF")
|
||||
@@ -155,10 +155,11 @@ func (s *ReaderService) ImportSources(ctx context.Context, text string) (int, er
|
||||
}
|
||||
// 记录该源是否声明封面解密:搜索结果(尚未入库)据此决定封面代理形态。
|
||||
s.markSourceCoverDecode(bs.BookSourceURL, strings.TrimSpace(SPtr(bs.CoverDecodeJs)) != "")
|
||||
// 已存在则更新,否则新建(按书源 URL 去重)
|
||||
existing, err := s.repo.GetSourceByURL(ctx, bs.BookSourceURL)
|
||||
// 已存在则更新,否则新建(按「用户 + 书源 URL」去重:书源按用户独立)
|
||||
existing, err := s.repo.GetSourceByURL(ctx, userID, bs.BookSourceURL)
|
||||
now := time.Now()
|
||||
record := &model.ReaderBookSource{
|
||||
UserID: userID,
|
||||
Name: bs.BookSourceName,
|
||||
GroupName: strings.TrimSpace(SPtr(bs.BookSourceGroup)),
|
||||
Type: bs.Type(),
|
||||
@@ -275,9 +276,9 @@ func ParseSourcePayload(text string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ListSources 书源列表(标注是否支持登录,供前端决定是否显示登录入口)。
|
||||
func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
// ListSources 当前用户的书源列表(标注是否支持登录,供前端决定是否显示登录入口)。
|
||||
func (s *ReaderService) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
|
||||
sources, err := s.repo.ListSources(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -311,9 +312,9 @@ func rawSourceHasLogin(rawJSON string) bool {
|
||||
return strings.TrimSpace(SPtr(probe.LoginURL)) != "" || strings.TrimSpace(SPtr(probe.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// UpdateSourceEnabled 启停书源。
|
||||
func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, id string, enabled bool) error {
|
||||
src, err := s.repo.GetSource(ctx, id)
|
||||
// UpdateSourceEnabled 启停书源(限当前用户自己的书源)。
|
||||
func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, userID, id string, enabled bool) error {
|
||||
src, err := s.repo.GetSourceForUser(ctx, userID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -321,8 +322,11 @@ func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, id string, enab
|
||||
return s.repo.UpdateSource(ctx, src)
|
||||
}
|
||||
|
||||
// DeleteSource 删除书源。
|
||||
func (s *ReaderService) DeleteSource(ctx context.Context, id string) error {
|
||||
// DeleteSource 删除书源(限当前用户自己的书源)。
|
||||
func (s *ReaderService) DeleteSource(ctx context.Context, userID, id string) error {
|
||||
if _, err := s.repo.GetSourceForUser(ctx, userID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.DeleteSource(ctx, id)
|
||||
}
|
||||
|
||||
@@ -790,7 +794,6 @@ func (s *ReaderService) readerFileCacheDir() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
// srcID 书源记录 ID(登录界面待办按书源隔离)。
|
||||
func (sess *sourceSession) srcID() string {
|
||||
if sess.src != nil {
|
||||
@@ -917,11 +920,11 @@ type searchHit struct {
|
||||
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
|
||||
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
|
||||
// searchUrl 里没有 {{page}} 的源会重复返回首页结果,由调用方按「书名+作者」去重。
|
||||
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
|
||||
func (s *ReaderService) Search(ctx context.Context, userID, key string, sourceIDs []string, page int) ([]SearchBook, []SearchSkipped, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
sources, err := s.repo.ListSources(ctx)
|
||||
sources, err := s.repo.ListSources(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -1867,7 +1870,7 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
|
||||
helper.StripAcceptEncoding(httpReq.Header)
|
||||
// 书源级请求头
|
||||
if s.repo != nil {
|
||||
if found, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
|
||||
if found, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && found != nil && found.Header != "" {
|
||||
var headers map[string]any
|
||||
if json.Unmarshal([]byte(found.Header), &headers) == nil {
|
||||
for k, v := range headers {
|
||||
@@ -2406,7 +2409,7 @@ func (s *ReaderService) DecodeImageBytes(ctx context.Context, book *model.Reader
|
||||
if js == "" {
|
||||
return data, nil
|
||||
}
|
||||
srcModel, err := s.repo.GetSourceByURL(ctx, book.Origin)
|
||||
srcModel, err := s.repo.GetSourceAnyByURL(ctx, book.Origin)
|
||||
if err != nil {
|
||||
return data, nil
|
||||
}
|
||||
@@ -2467,7 +2470,7 @@ func (s *ReaderService) fetchSourceResource(ctx context.Context, sourceURL, rawU
|
||||
if err != nil || bs == nil {
|
||||
return nil, fmt.Errorf("书源不存在")
|
||||
}
|
||||
srcModel, err := s.repo.GetSourceByURL(ctx, sourceURL)
|
||||
srcModel, err := s.repo.GetSourceAnyByURL(ctx, sourceURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2605,8 +2608,10 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
|
||||
return book, nil
|
||||
}
|
||||
|
||||
// ClearBookOriginCacheIfUnreferenced 清理 (origin, bookURL) 的正文缓存,
|
||||
// ClearBookOriginCacheIfUnreferenced 清理某本网络书的正文缓存,
|
||||
// 但仅当没有其他书架记录仍在引用它时(同一本书可能被多个用户收藏)。
|
||||
//
|
||||
// 缓存按「书源行 + 书本地址」组织:换源后旧地址的缓存不会再命中,这里负责回收空间。
|
||||
func (s *ReaderService) ClearBookOriginCacheIfUnreferenced(ctx context.Context, origin, bookURL, excludeBookID string) {
|
||||
if s == nil || s.repo == nil || origin == "" || bookURL == "" {
|
||||
return
|
||||
@@ -2619,7 +2624,11 @@ func (s *ReaderService) ClearBookOriginCacheIfUnreferenced(ctx context.Context,
|
||||
}
|
||||
}
|
||||
}
|
||||
s.ClearContentCacheForBook(ctx, origin, bookURL)
|
||||
sourceID := s.contentSourceID(ctx, nil, &model.ReaderBook{Origin: origin})
|
||||
if sourceID == "" {
|
||||
return
|
||||
}
|
||||
s.ClearContentCacheForBook(ctx, sourceID, bookURL)
|
||||
}
|
||||
|
||||
// GetBook 按 ID 取书(媒体代理等使用)。
|
||||
@@ -3134,13 +3143,13 @@ func deepCopyChapterContent(in *ChapterContent) *ChapterContent {
|
||||
|
||||
// ContentBatchItem 批量取正文的单章结果。
|
||||
type ContentBatchItem struct {
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tracks []string `json:"tracks,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ImageStyle string `json:"image_style,omitempty"`
|
||||
IsHLS bool `json:"hls,omitempty"`
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Tracks []string `json:"tracks,omitempty"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ImageStyle string `json:"image_style,omitempty"`
|
||||
IsHLS bool `json:"hls,omitempty"`
|
||||
Comments []ContentComment `json:"comments,omitempty"`
|
||||
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
|
||||
Cached bool `json:"cached"`
|
||||
@@ -3418,8 +3427,8 @@ func (s *ReaderService) SmokeSource(ctx context.Context, raw string, key string)
|
||||
}
|
||||
|
||||
// Debug 书源调试接口:返回逐条日志字符串(前端展示用)。
|
||||
func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]string, error) {
|
||||
src, bs, err := s.loadSource(ctx, sourceID)
|
||||
func (s *ReaderService) Debug(ctx context.Context, userID, sourceID, key string) ([]string, error) {
|
||||
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -3448,6 +3457,19 @@ func (s *ReaderService) loadSource(ctx context.Context, sourceID string) (*model
|
||||
return src, bs, nil
|
||||
}
|
||||
|
||||
// loadSourceForUser 加载书源并校验归属:书源管理/登录/调试等「用户接口」必须走这条。
|
||||
func (s *ReaderService) loadSourceForUser(ctx context.Context, userID, sourceID string) (*model.ReaderBookSource, *BookSource, error) {
|
||||
src, err := s.repo.GetSourceForUser(ctx, userID, sourceID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("书源不存在或不属于当前用户")
|
||||
}
|
||||
bs, err := ParseBookSource(src.RawJSON)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("书源 JSON 解析失败: %w", err)
|
||||
}
|
||||
return src, bs, nil
|
||||
}
|
||||
|
||||
// loadSourceFlexible 按 ID 或 URL 加载书源(书架上只存 origin URL,
|
||||
// 前端阅读链路用 source_url 定位书源)。
|
||||
func (s *ReaderService) loadSourceFlexible(ctx context.Context, sourceID, sourceURL string) (*model.ReaderBookSource, *BookSource, error) {
|
||||
@@ -3457,7 +3479,7 @@ func (s *ReaderService) loadSourceFlexible(ctx context.Context, sourceID, source
|
||||
if sourceURL == "" {
|
||||
return nil, nil, fmt.Errorf("缺少书源标识(source_id 或 source_url)")
|
||||
}
|
||||
src, err := s.repo.GetSourceByURL(ctx, sourceURL)
|
||||
src, err := s.repo.GetSourceAnyByURL(ctx, sourceURL)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("书源不存在或已被删除")
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ import (
|
||||
//
|
||||
// 设计要点:
|
||||
// - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后,
|
||||
// 用户替换规则、签名代理改写之前)。因此同一本书多用户共享同一份缓存,
|
||||
// 用户替换规则、签名代理改写之前)。因此缓存里不含任何用户维度的处理结果,
|
||||
// 用户替换规则在读出后逐请求应用,规则改动即时生效。
|
||||
// - 索引落库(ReaderContentCache),内容落盘(cache_dir/reader-content/<origin>/<bookKey>/<key>.json)。
|
||||
// - 缓存键只包含书源身份、章节身份、内容类型、书源指纹与格式版本:不含书籍行 ID
|
||||
// (目录刷新会重建行)、不含用户(用户维度在读出后处理)。
|
||||
// - 缓存键按书源行组织(书源按用户独立,见 contentBookKey 的说明),
|
||||
// 不同用户即使持有同一 URL 的书源也各自一份缓存。
|
||||
// - 索引落库(ReaderContentCache),内容落盘
|
||||
// (cache_dir/reader-content/<sourceKey>/<bookKey>/<chapterKey>.json)。
|
||||
// - 缓存键只包含书源行、章节身份、书源指纹与格式版本:不含书籍行 ID
|
||||
// (目录刷新会重建行,见 RemapContentCacheOnTocChange)。
|
||||
|
||||
// readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记)
|
||||
// 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。
|
||||
@@ -60,10 +63,19 @@ type contentFlight struct {
|
||||
|
||||
// ─── 键与身份 ──────────────────────────────────────────────────────────────
|
||||
|
||||
// contentBookKey 书源身份哈希:origin + 书本地址。
|
||||
// 用「文件地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞在一起。
|
||||
func contentBookKey(origin, bookURL string) string {
|
||||
sum := sha256.Sum256([]byte(origin + "\x00" + strings.TrimSpace(bookURL)))
|
||||
// contentBookKey 书源身份哈希:书源行 ID + 书本地址。
|
||||
//
|
||||
// 用书源行 ID 而不是 origin(书源 URL):书源按用户独立,不同用户可以持有同一
|
||||
// URL 但规则/header 不同的副本,按 URL 共享缓存会把别人书源的产物喂进来。
|
||||
// 用「行 ID + 书本地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞。
|
||||
func contentBookKey(sourceID, bookURL string) string {
|
||||
sum := sha256.Sum256([]byte(sourceID + "\x00" + strings.TrimSpace(bookURL)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
// contentOriginKey 书源行维度的磁盘目录名(同一书源的不同书共享这一层)。
|
||||
func contentOriginKey(sourceID string) string {
|
||||
sum := sha256.Sum256([]byte(sourceID))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
@@ -116,24 +128,23 @@ func (s *ReaderService) readerContentDir() string {
|
||||
return filepath.Join(base, "reader-content")
|
||||
}
|
||||
|
||||
// contentFilePath 单条缓存的磁盘路径:<root>/<originHash>/<bookKey>/<chapterKey>.json。
|
||||
// contentFilePath 单条缓存的磁盘路径:<root>/<sourceKey>/<bookKey>/<chapterKey>.json。
|
||||
// chapterKey 已是 hex 哈希,不含路径分隔符。
|
||||
func (s *ReaderService) contentFilePath(origin, bookURL, chapterKey string) string {
|
||||
func (s *ReaderService) contentFilePath(sourceID, bookURL, chapterKey string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
originHash := contentBookKey(origin, "")
|
||||
return filepath.Join(root, originHash, contentBookKey(origin, bookURL), chapterKey+".json")
|
||||
return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL), chapterKey+".json")
|
||||
}
|
||||
|
||||
// contentBookDir 某本书的缓存目录(整本清理用)。
|
||||
func (s *ReaderService) contentBookDir(origin, bookURL string) string {
|
||||
func (s *ReaderService) contentBookDir(sourceID, bookURL string) string {
|
||||
root := s.readerContentDir()
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(root, contentBookKey(origin, ""), contentBookKey(origin, bookURL))
|
||||
return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL))
|
||||
}
|
||||
|
||||
// writeContentFile 原子写入缓存文件(临时文件 + rename)。
|
||||
@@ -175,6 +186,22 @@ func removeContentFile(path string) {
|
||||
|
||||
// ─── 读写 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// contentSourceID 缓存键用的书源行 ID。src 缺失时按 origin 兜底查一次:
|
||||
// 直接调用(测试、诊断路径)可能只拿到书籍与 origin。
|
||||
func (s *ReaderService) contentSourceID(ctx context.Context, src *model.ReaderBookSource, book *model.ReaderBook) string {
|
||||
if src != nil && src.ID != "" {
|
||||
return src.ID
|
||||
}
|
||||
if s == nil || s.repo == nil || book == nil {
|
||||
return ""
|
||||
}
|
||||
found, err := s.repo.GetSourceAnyByURL(ctx, book.Origin)
|
||||
if err != nil || found == nil {
|
||||
return ""
|
||||
}
|
||||
return found.ID
|
||||
}
|
||||
|
||||
// loadCachedContent 读取一章节的缓存:命中返回内容与 true。
|
||||
//
|
||||
// contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时
|
||||
@@ -185,7 +212,11 @@ func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.Reader
|
||||
if s == nil || s.repo == nil || book == nil {
|
||||
return nil, false
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
sourceID := s.contentSourceID(ctx, src, book)
|
||||
if sourceID == "" {
|
||||
return nil, false
|
||||
}
|
||||
bookKey := contentBookKey(sourceID, book.BookURL)
|
||||
chapterKey := contentChapterKey(book, ch)
|
||||
row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType)
|
||||
if err != nil || row == nil {
|
||||
@@ -205,7 +236,7 @@ func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.Reader
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
return nil, false
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, chapterKey)
|
||||
path := s.contentFilePath(sourceID, book.BookURL, chapterKey)
|
||||
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
|
||||
if err != nil {
|
||||
s.dropContentCacheRow(ctx, row)
|
||||
@@ -253,7 +284,11 @@ func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.Reader
|
||||
if err != nil || len(raw) > readerContentMaxEntryBytes {
|
||||
return
|
||||
}
|
||||
path := s.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, ch))
|
||||
sourceID := s.contentSourceID(ctx, src, book)
|
||||
if sourceID == "" {
|
||||
return
|
||||
}
|
||||
path := s.contentFilePath(sourceID, book.BookURL, contentChapterKey(book, ch))
|
||||
if err := writeContentFile(path, raw); err != nil {
|
||||
if s.log != nil {
|
||||
s.log.Warn("reader: 写入正文缓存失败", zap.String("path", path), zap.Error(err))
|
||||
@@ -262,8 +297,8 @@ func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.Reader
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
row := &model.ReaderContentCache{
|
||||
OriginHash: contentBookKey(book.Origin, ""),
|
||||
BookKey: contentBookKey(book.Origin, book.BookURL),
|
||||
OriginHash: contentOriginKey(sourceID),
|
||||
BookKey: contentBookKey(sourceID, book.BookURL),
|
||||
ChapterKey: contentChapterKey(book, ch),
|
||||
ChapterIdentity: contentChapterIdentity(book, ch),
|
||||
ChapterIndex: ch.Index,
|
||||
@@ -318,17 +353,19 @@ func (s *ReaderService) pruneContentDirIfEmpty(dir string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ClearContentCacheForBook 清理一本书的全部正文缓存(换源/移出书架时调用)。
|
||||
// 同源同书可能被多个用户收藏:仍被引用时不删(见 DeleteBook/ClearBookOrigin)。
|
||||
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, origin, bookURL string) {
|
||||
if s == nil || s.repo == nil || origin == "" || bookURL == "" {
|
||||
// ClearContentCacheForBook 清理某书源行下一本书的全部正文缓存(换源/移出书架时调用)。
|
||||
//
|
||||
// sourceID 是书源行 ID:书源按用户独立,缓存也随之按书源行隔离,所以删除
|
||||
// 自己那份书源或换源不会影响其他用户的同名书源缓存。
|
||||
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, sourceID, bookURL string) {
|
||||
if s == nil || s.repo == nil || sourceID == "" || bookURL == "" {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(origin, bookURL)
|
||||
bookKey := contentBookKey(sourceID, bookURL)
|
||||
if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil {
|
||||
return
|
||||
}
|
||||
if dir := s.contentBookDir(origin, bookURL); dir != "" {
|
||||
if dir := s.contentBookDir(sourceID, bookURL); dir != "" {
|
||||
_ = os.RemoveAll(dir)
|
||||
s.pruneContentDirIfEmpty(filepath.Dir(dir))
|
||||
}
|
||||
@@ -403,7 +440,11 @@ func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *
|
||||
if s == nil || s.repo == nil || book == nil || len(oldChapters) == 0 || len(newChapters) == 0 {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(book.Origin, book.BookURL)
|
||||
sourceID := s.contentSourceID(ctx, nil, book)
|
||||
if sourceID == "" {
|
||||
return
|
||||
}
|
||||
bookKey := contentBookKey(sourceID, book.BookURL)
|
||||
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return
|
||||
@@ -445,7 +486,7 @@ func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *
|
||||
continue // 身份未变,只更新序号
|
||||
}
|
||||
oldPath := s.contentPathFromRow(&row)
|
||||
newPath := s.contentFilePath(book.Origin, book.BookURL, newKey)
|
||||
newPath := s.contentFilePath(sourceID, book.BookURL, newKey)
|
||||
if oldPath != "" && newPath != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil {
|
||||
// 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。
|
||||
|
||||
@@ -144,8 +144,12 @@ func TestContentCacheHitAndPersist(t *testing.T) {
|
||||
t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got)
|
||||
}
|
||||
|
||||
// 缓存文件确实落盘
|
||||
path := svc.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, model.ReaderChapter{
|
||||
// 缓存文件确实落盘(键按书源行 ID 组织)。
|
||||
sourceID := svc.contentSourceID(ctx, nil, book)
|
||||
if sourceID == "" {
|
||||
t.Fatal("找不到书源行")
|
||||
}
|
||||
path := svc.contentFilePath(sourceID, book.BookURL, contentChapterKey(book, model.ReaderChapter{
|
||||
Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html",
|
||||
}))
|
||||
if path == "" {
|
||||
@@ -279,7 +283,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
|
||||
}
|
||||
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
|
||||
updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1)
|
||||
if _, err := svc.ImportSources(ctx, updated); err != nil {
|
||||
if _, err := svc.ImportSources(ctx, "u1", updated); err != nil {
|
||||
t.Fatalf("更新书源失败: %v", err)
|
||||
}
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
@@ -297,7 +301,8 @@ func TestContentCacheClearedOnRemoveBook(t *testing.T) {
|
||||
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
|
||||
t.Fatalf("读取失败: %v", err)
|
||||
}
|
||||
dir := svc.contentBookDir(book.Origin, book.BookURL)
|
||||
sourceID := svc.contentSourceID(ctx, nil, book)
|
||||
dir := svc.contentBookDir(sourceID, book.BookURL)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Fatalf("缓存目录应存在: %v", err)
|
||||
}
|
||||
@@ -307,7 +312,7 @@ func TestContentCacheClearedOnRemoveBook(t *testing.T) {
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Fatalf("移出书架后缓存目录应被清理,err=%v", err)
|
||||
}
|
||||
if rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(book.Origin, book.BookURL)); err != nil || len(rows) != 0 {
|
||||
if rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(sourceID, book.BookURL)); err != nil || len(rows) != 0 {
|
||||
t.Fatalf("缓存索引应清空: rows=%d err=%v", len(rows), err)
|
||||
}
|
||||
}
|
||||
@@ -318,10 +323,12 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
|
||||
ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}
|
||||
svc.saveCachedContent(ctx, mustSource(t, svc, srv.URL), book, ch, &ChapterContent{
|
||||
src := mustSource(t, svc, srv.URL)
|
||||
bookKey := contentBookKey(src.ID, book.BookURL)
|
||||
svc.saveCachedContent(ctx, src, book, ch, &ChapterContent{
|
||||
Type: "audio", Tracks: []string{srv.URL + "/a.mp3"},
|
||||
})
|
||||
row, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio")
|
||||
row, err := svc.repo.GetContentCache(ctx, bookKey, contentChapterKey(book, ch), "audio")
|
||||
if err != nil {
|
||||
t.Fatalf("音频缓存未写入: %v", err)
|
||||
}
|
||||
@@ -333,10 +340,10 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
|
||||
if err := svc.repo.UpsertContentCache(ctx, row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := svc.loadCachedContent(ctx, mustSource(t, svc, srv.URL), nil, book, ch, "audio"); ok {
|
||||
if _, ok := svc.loadCachedContent(ctx, src, nil, book, ch, "audio"); ok {
|
||||
t.Fatal("过期缓存不应命中")
|
||||
}
|
||||
if _, err := svc.repo.GetContentCache(ctx, contentBookKey(book.Origin, book.BookURL), contentChapterKey(book, ch), "audio"); err == nil {
|
||||
if _, err := svc.repo.GetContentCache(ctx, bookKey, contentChapterKey(book, ch), "audio"); err == nil {
|
||||
t.Fatal("过期条目应被删除")
|
||||
}
|
||||
}
|
||||
@@ -344,7 +351,7 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
|
||||
// mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。
|
||||
func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource {
|
||||
t.Helper()
|
||||
src, err := svc.repo.GetSourceByURL(context.Background(), sourceURL)
|
||||
src, err := svc.repo.GetSourceByURL(context.Background(), "u1", sourceURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取书源失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestSearchUsesRequestedPage(t *testing.T) {
|
||||
{0, "1"}, // 非法页码退回第 1 页
|
||||
{-3, "1"}, // 负数同理
|
||||
} {
|
||||
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
|
||||
books, skipped, err := svc.Search(t.Context(), "u1", "关键词", nil, tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
|
||||
|
||||
books, _, err := svc.Search(t.Context(), "书", nil, 3)
|
||||
books, _, err := svc.Search(t.Context(), "u1", "书", nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -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, 1)
|
||||
books, skipped, err := svc.Search(t.Context(), "u1", "宠魅", 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, 1)
|
||||
books, skipped, err := svc.Search(t.Context(), "u1", "宠魅", 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, 1); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "u1", "宠魅", nil, 1); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -53,11 +53,11 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||
} {
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
if _, err := svc.ImportSources(t.Context(), "u1", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sources, err := svc.ListSources(t.Context())
|
||||
sources, err := svc.ListSources(t.Context(), "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
|
||||
books, skipped, err := svc.Search(t.Context(), "u1", "书", []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, 1)
|
||||
books, _, err = svc.Search(t.Context(), "u1", "书", 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"}, 1)
|
||||
books, _, err = svc.Search(t.Context(), "u1", "书", []string{"not-a-real-source"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
imported, err := svc.ImportSources(ctx, messySourceJSON)
|
||||
imported, err := svc.ImportSources(ctx, "u1", messySourceJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
@@ -46,7 +46,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
t.Fatalf("导入数量 = %d,期望 1", imported)
|
||||
}
|
||||
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "u1", "https://www.mangacopy.com/")
|
||||
if err != nil || bs == nil {
|
||||
t.Fatalf("按 URL 查不到导入的书源: %v", err)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func TestParseBookSourceRejectsGarbage(t *testing.T) {
|
||||
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
_, err := svc.ImportSources(t.Context(), "u1", `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("一条都没导入时应当返回错误")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 书源按用户独立:同一 URL 的两个用户各自持有副本,启停/删除/搜索互不影响。
|
||||
|
||||
func TestSourcesArePerUser(t *testing.T) {
|
||||
srv := newCacheTestServer()
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
raw := cacheTestSourceJSON(srv.URL)
|
||||
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
|
||||
t.Fatalf("u1 导入失败: %v", err)
|
||||
}
|
||||
// 第二个用户导入同一书源 URL:旧实现会撞 source_url 唯一索引(导入失败)。
|
||||
if _, err := svc.ImportSources(ctx, "u2", raw); err != nil {
|
||||
t.Fatalf("u2 导入同一书源失败(书源未按用户隔离): %v", err)
|
||||
}
|
||||
|
||||
u1Sources, err := svc.ListSources(ctx, "u1")
|
||||
if err != nil || len(u1Sources) != 1 {
|
||||
t.Fatalf("u1 应有 1 个书源: n=%d err=%v", len(u1Sources), err)
|
||||
}
|
||||
u2Sources, err := svc.ListSources(ctx, "u2")
|
||||
if err != nil || len(u2Sources) != 1 {
|
||||
t.Fatalf("u2 应有 1 个书源: n=%d err=%v", len(u2Sources), err)
|
||||
}
|
||||
if u1Sources[0].ID == u2Sources[0].ID {
|
||||
t.Fatal("两个用户应各持一份副本(行 ID 不同)")
|
||||
}
|
||||
if u1Sources[0].UserID != "u1" || u2Sources[0].UserID != "u2" {
|
||||
t.Fatalf("书源归属错误: u1=%q u2=%q", u1Sources[0].UserID, u2Sources[0].UserID)
|
||||
}
|
||||
|
||||
// 启停互不影响。
|
||||
if err := svc.UpdateSourceEnabled(ctx, "u1", u1Sources[0].ID, false); err != nil {
|
||||
t.Fatalf("u1 停用失败: %v", err)
|
||||
}
|
||||
u2After, _ := svc.ListSources(ctx, "u2")
|
||||
if len(u2After) != 1 || !u2After[0].Enabled {
|
||||
t.Fatal("u1 停用影响到了 u2 的书源")
|
||||
}
|
||||
|
||||
// 删除只删自己那份。
|
||||
if err := svc.DeleteSource(ctx, "u1", u1Sources[0].ID); err != nil {
|
||||
t.Fatalf("u1 删除失败: %v", err)
|
||||
}
|
||||
if left, _ := svc.ListSources(ctx, "u1"); len(left) != 0 {
|
||||
t.Fatalf("u1 应已无书源: n=%d", len(left))
|
||||
}
|
||||
if left, _ := svc.ListSources(ctx, "u2"); len(left) != 1 {
|
||||
t.Fatalf("u2 的书源被误删: n=%d", len(left))
|
||||
}
|
||||
|
||||
// 越权删除他人书源必须失败。
|
||||
if err := svc.DeleteSource(ctx, "u1", u2Sources[0].ID); err == nil {
|
||||
t.Fatal("u1 不应能删除 u2 的书源")
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索只使用当前用户的书源范围。
|
||||
func TestSearchUsesOwnSourcesOnly(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.ImportSources(ctx, "u1", e2eSourceJSON(srv.URL)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// u2 没有任何书源:搜索应直接报「没有已启用的书源」,而不是用 u1 的源。
|
||||
if _, _, err := svc.Search(ctx, "u2", "书", nil, 1); err == nil || !strings.Contains(err.Error(), "没有已启用的书源") {
|
||||
t.Fatalf("u2 无书源时应拒绝搜索,实际 err=%v", err)
|
||||
}
|
||||
// u1 自己的搜索正常。
|
||||
books, _, err := svc.Search(ctx, "u1", "书", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("u1 搜索失败: %v", err)
|
||||
}
|
||||
if len(books) == 0 {
|
||||
t.Fatal("u1 应搜到结果")
|
||||
}
|
||||
}
|
||||
|
||||
// 同一书源 URL 的两个用户副本:删除再导入仍然可用(物理删除释放唯一键)。
|
||||
func TestDeleteAndReimportSameSource(t *testing.T) {
|
||||
srv := newCacheTestServer()
|
||||
defer srv.Close()
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if _, err := svc.ImportSources(ctx, "u1", cacheTestSourceJSON(srv.URL)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources, _ := svc.ListSources(ctx, "u1")
|
||||
if err := svc.DeleteSource(ctx, "u1", sources[0].ID); err != nil {
|
||||
t.Fatalf("删除失败: %v", err)
|
||||
}
|
||||
if _, err := svc.ImportSources(ctx, "u1", cacheTestSourceJSON(srv.URL)); err != nil {
|
||||
t.Fatalf("删除后重新导入失败: %v", err)
|
||||
}
|
||||
if again, _ := svc.ListSources(ctx, "u1"); len(again) != 1 {
|
||||
t.Fatalf("重新导入后应有 1 个书源: n=%d", len(again))
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
// importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。
|
||||
func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string {
|
||||
t.Helper()
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
if _, err := svc.ImportSources(t.Context(), "u1", raw); err != nil {
|
||||
t.Fatalf("导入书源失败: %v", err)
|
||||
}
|
||||
srcs, err := svc.ListSources(t.Context())
|
||||
srcs, err := svc.ListSources(t.Context(), "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user