diff --git a/docs/reader-plan.md b/docs/reader-plan.md index 3f7e59a..f026466 100644 --- a/docs/reader-plan.md +++ b/docs/reader-plan.md @@ -126,11 +126,22 @@ web/src/ | P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 | | P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 | | P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/.` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 | +| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 | | P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 | | P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 | | P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 | | P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 | +### 实施记录:书源会话状态仍是跨用户共享(下一步) + +书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量) +仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时: + +- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。 + +彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写 +的一批调用点,留作单独一轮处理。 + ### 实施记录:关于「宽松 @put 触发 panic」的复核 前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。 diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index bf42c78..69297fb 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -36,6 +36,7 @@ - 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。 - 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。 - 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。 +- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。 **书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** - 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。 diff --git a/internal/database/reader_source_migration_test.go b/internal/database/reader_source_migration_test.go new file mode 100644 index 0000000..648b8ea --- /dev/null +++ b/internal/database/reader_source_migration_test.go @@ -0,0 +1,141 @@ +package database + +import ( + "path/filepath" + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" +) + +// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。 +type legacyReaderSource struct { + ID string `gorm:"primaryKey"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Name string + SourceURL string `gorm:"type:varchar(512);uniqueIndex"` + RawJSON string + Enabled bool + EnabledExplore bool + Variables string + Header string +} + +func (legacyReaderSource) TableName() string { return "reader_book_sources" } + +// 迁移路径:老库(书源全局唯一、无 user_id)升级后 +// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本; +// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本; +// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。 +func TestReaderSourceMigrationBackfill(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + // Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。 + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + // 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。 + if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil { + t.Fatal(err) + } + shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true} + if err := db.Create(shared).Error; err != nil { + t.Fatal(err) + } + softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"} + if err := db.Create(softDeleted).Error; err != nil { + t.Fatal(err) + } + if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删 + t.Fatal(err) + } + // u1 两本、u2 一本都引用共享源;孤儿源无人引用。 + books := []model.ReaderBook{ + {UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"}, + {UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"}, + {UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"}, + {UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"}, + } + for i := range books { + if err := db.Create(&books[i]).Error; err != nil { + t.Fatal(err) + } + } + + // 迁移。 + if err := dropLegacyReaderSourceUniqueIndex(db); err != nil { + t.Fatalf("删旧索引失败: %v", err) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("AutoMigrate 失败: %v", err) + } + if err := backfillReaderSourceOwnership(db); err != nil { + t.Fatalf("回填失败: %v", err) + } + + // 1. 旧唯一索引已删除:两个用户同 URL 各自一份。 + var rows []model.ReaderBookSource + if err := db.Order("user_id ASC").Find(&rows).Error; err != nil { + t.Fatal(err) + } + byUser := map[string]model.ReaderBookSource{} + for _, r := range rows { + if r.SourceURL == "https://shared.example.com" { + byUser[r.UserID] = r + } + } + if len(byUser) != 2 { + t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser) + } + if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID { + t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID) + } + if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" { + t.Fatal("副本应保留原书源内容") + } + // 引用最多的用户保留原行(u1 有两本书)。 + if byUser["u1"].ID != "legacy-1" { + t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID) + } + + // 2. 软删的历史行被清理。 + var softLeft int64 + if err := db.Unscoped().Model(&model.ReaderBookSource{}). + Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil { + t.Fatal(err) + } + if softLeft != 0 { + t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft) + } + + // 3. 无人引用的书源保持无归属(不会凭空分配给谁)。 + var orphans int64 + if err := db.Model(&model.ReaderBookSource{}). + Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil { + t.Fatal(err) + } + if orphans != 0 { + t.Fatal("无人引用的书源不应被分配归属") + } + + // 4. 回填是幂等的(再次执行不产生重复副本)。 + if err := backfillReaderSourceOwnership(db); err != nil { + t.Fatal(err) + } + var total int64 + if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil { + t.Fatal(err) + } + if total != 2 { + t.Fatalf("重复回填不应新增副本,实际 %d 份", total) + } +} diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 856ce11..a9cc436 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -13,9 +13,19 @@ func AutoMigrate(db *gorm.DB) error { if err := dedupePlaybackHistories(db); err != nil { return err } + // 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。 + // AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它 + // 第二个用户导入同一书源就会撞唯一约束。 + if err := dropLegacyReaderSourceUniqueIndex(db); err != nil { + return err + } if err := db.AutoMigrate(model.AllModels()...); err != nil { return err } + // 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。 + if err := backfillReaderSourceOwnership(db); err != nil { + return err + } if err := ensurePostgresColumnCompatibility(db); err != nil { return err } @@ -42,6 +52,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error { return db.Exec("ANALYZE").Error } +// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。 +// +// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与 +// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效), +// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。 +func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.ReaderBookSource{}) { + return nil + } + for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} { + if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) { + if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil { + return err + } + } + } + // 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键, + // 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。 + return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error +} + +// backfillReaderSourceOwnership 给老库的书源补上归属用户。 +// +// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户; +// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则), +// 这样谁都不会因为别人删源而失去书源。 +// +// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的 +// 列表里,但数据仍在,需要时可以人工认领。 +func backfillReaderSourceOwnership(db *gorm.DB) error { + if !db.Migrator().HasTable(&model.ReaderBookSource{}) { + return nil + } + var pending []model.ReaderBookSource + if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil { + return err + } + for i := range pending { + src := pending[i] + if err := assignOrCloneReaderSource(db, &src); err != nil { + return err + } + } + return nil +} + +func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error { + if src == nil || src.SourceURL == "" { + return nil + } + // 引用该书源的书籍分布(去重到用户)。 + type ownerCount struct { + UserID string + N int64 + } + var owners []ownerCount + if err := db.Model(&model.ReaderBook{}). + Select("user_id, COUNT(*) AS n"). + Where("origin = ?", src.SourceURL). + Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil { + return err + } + if len(owners) == 0 { + return nil // 无人引用:保持无归属 + } + // 第一个用户(引用最多)直接接手这一行。 + owner := owners[0].UserID + if owner == "" { + return nil + } + if err := db.Model(&model.ReaderBookSource{}). + Where("id = ?", src.ID). + Updates(map[string]any{"user_id": owner}).Error; err != nil { + return err + } + // 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。 + for _, o := range owners[1:] { + if o.UserID == "" || o.UserID == owner { + continue + } + var exists int64 + if err := db.Model(&model.ReaderBookSource{}). + Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL). + Count(&exists).Error; err != nil { + return err + } + if exists > 0 { + continue + } + clone := *src + clone.ID = "" + clone.UserID = o.UserID + if err := db.Create(&clone).Error; err != nil { + return err + } + } + return nil +} + // dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by // the former read-then-write upsert, so the uniq_user_history composite unique // index can be created on existing databases. Keeps the most recent row per diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index fb33e0c..0ac476b 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -90,7 +90,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) { func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - sources, err := svc.Reader.ListSources(c.Request.Context()) + userID := c.GetString(middleware.CtxUserID) + sources, err := svc.Reader.ListSources(c.Request.Context(), userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -108,7 +109,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text) + userID := c.GetString(middleware.CtxUserID) + imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -126,7 +128,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"}) return } - if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil { + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -136,7 +139,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc { func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil { + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -153,7 +157,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key) + userID := c.GetString(middleware.CtxUserID) + logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -318,7 +323,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc { func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil { + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -335,7 +341,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil { + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -352,7 +359,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil { + userID := c.GetString(middleware.CtxUserID) + if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -373,7 +381,8 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page) + userID := c.GetString(middleware.CtxUserID) + books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return diff --git a/internal/model/reader.go b/internal/model/reader.go index b6fcfcb..358c49e 100644 --- a/internal/model/reader.go +++ b/internal/model/reader.go @@ -8,12 +8,19 @@ import ( ) // ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。 +// +// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量), +// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠 +// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。 type ReaderBookSource struct { Base + // UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」, + // 只在没有任何用户引用时才会出现。 + UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"` Name string `gorm:"type:varchar(255);index" json:"name"` GroupName string `gorm:"type:varchar(255);index" json:"group"` Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频 - SourceURL string `gorm:"type:varchar(512);index" json:"source_url"` + SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"` RawJSON string `gorm:"type:text" json:"-"` Enabled bool `gorm:"default:true" json:"enabled"` EnabledExplore bool `gorm:"default:true" json:"enabled_explore"` diff --git a/internal/repository/reader_repository.go b/internal/repository/reader_repository.go index eb95f5c..963cea7 100644 --- a/internal/repository/reader_repository.go +++ b/internal/repository/reader_repository.go @@ -15,10 +15,11 @@ type ReaderRepository struct { db *gorm.DB } -// ListSources 书源列表(按 customOrder 排序)。 -func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) { +// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。 +func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) { var out []model.ReaderBookSource - err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error + err := r.db.WithContext(ctx).Where("user_id = ?", userID). + Order("custom_order ASC, updated_at DESC").Find(&out).Error return out, err } @@ -31,8 +32,31 @@ func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.Rea return &out, nil } -// GetSourceByURL 按书源 URL 取书源(导入去重用)。 -func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) { +// GetSourceForUser 按 ID 取书源并校验归属用户。 +func (r *ReaderRepository) GetSourceForUser(ctx context.Context, userID, id string) (*model.ReaderBookSource, error) { + var out model.ReaderBookSource + if err := r.db.WithContext(ctx).First(&out, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &out, nil +} + +// GetSourceByURL 按用户 + 书源 URL 取书源(导入去重用)。 +func (r *ReaderRepository) GetSourceByURL(ctx context.Context, userID, sourceURL string) (*model.ReaderBookSource, error) { + var out model.ReaderBookSource + err := r.db.WithContext(ctx).First(&out, "user_id = ? AND source_url = ?", userID, sourceURL).Error + if err != nil { + return nil, err + } + return &out, nil +} + +// GetSourceAnyByURL 按书源 URL 取任意一份副本(不限定用户)。 +// +// 媒体代理、目录刷新等路径只拿得到书籍的 origin(书源 URL)而没有用户上下文; +// 请求目标由 URL 决定,任一副本都够用。多个用户各自改过规则/header 时, +// 取到哪一份只影响「用谁的默认 header」,不影响书籍本身的定位。 +func (r *ReaderRepository) GetSourceAnyByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) { var out model.ReaderBookSource err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error if err != nil { @@ -41,6 +65,13 @@ func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) return &out, nil } +// ListSourceRowsByURL 取某书源 URL 的全部副本(判断是否仍有其他用户引用)。 +func (r *ReaderRepository) ListSourceRowsByURL(ctx context.Context, sourceURL string) ([]model.ReaderBookSource, error) { + var out []model.ReaderBookSource + err := r.db.WithContext(ctx).Where("source_url = ?", sourceURL).Find(&out).Error + return out, err +} + // CreateSource 新增书源。 func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error { return r.db.WithContext(ctx).Create(src).Error @@ -52,20 +83,34 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo } // DeleteSource 删除书源(连带清理其会话状态)。 +// +// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行—— +// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的 +// 正常操作。书源本身是可重新导入的数据,不需要软删保留。 +// +// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何 +// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。 func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { src := &model.ReaderBookSource{} if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" { - // 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引: - // 软删会让这一行继续占着 source_url,之后 SaveSourceState 的 - // First(默认排除软删行)查不到、Create 就会撞唯一约束, - // 表现为「保存书源会话状态失败: UNIQUE constraint failed」, - // cookie / 登录态从此再也存不进去。这里必须硬删。 - if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil { + var others int64 + if err := tx.Model(&model.ReaderBookSource{}). + Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil { return err } + if others == 0 { + // 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引: + // 软删会让这一行继续占着 source_url,之后 SaveSourceState 的 + // First(默认排除软删行)查不到、Create 就会撞唯一约束, + // 表现为「保存书源会话状态失败: UNIQUE constraint failed」, + // cookie / 登录态从此再也存不进去。这里必须硬删。 + if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil { + return err + } + } } - return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error + return tx.Unscoped().Delete(&model.ReaderBookSource{}, "id = ?", id).Error }) } diff --git a/internal/service/reader/audio_transcode.go b/internal/service/reader/audio_transcode.go index 2929c91..4b82e05 100644 --- a/internal/service/reader/audio_transcode.go +++ b/internal/service/reader/audio_transcode.go @@ -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 { diff --git a/internal/service/reader/browser_panel.go b/internal/service/reader/browser_panel.go index 3d62a9e..7fa45a7 100644 --- a/internal/service/reader/browser_panel.go +++ b/internal/service/reader/browser_panel.go @@ -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 { diff --git a/internal/service/reader/comment.go b/internal/service/reader/comment.go index 1b0a3aa..330d699 100644 --- a/internal/service/reader/comment.go +++ b/internal/service/reader/comment.go @@ -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) == "" { diff --git a/internal/service/reader/image_decode_test.go b/internal/service/reader/image_decode_test.go index d54a9e2..70dfe37 100644 --- a/internal/service/reader/image_decode_test.go +++ b/internal/service/reader/image_decode_test.go @@ -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//.json)。 -// - 缓存键只包含书源身份、章节身份、内容类型、书源指纹与格式版本:不含书籍行 ID -// (目录刷新会重建行)、不含用户(用户维度在读出后处理)。 +// - 缓存键按书源行组织(书源按用户独立,见 contentBookKey 的说明), +// 不同用户即使持有同一 URL 的书源也各自一份缓存。 +// - 索引落库(ReaderContentCache),内容落盘 +// (cache_dir/reader-content///.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 单条缓存的磁盘路径:///.json。 +// contentFilePath 单条缓存的磁盘路径:///.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 { // 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。 diff --git a/internal/service/reader/reader_content_cache_test.go b/internal/service/reader/reader_content_cache_test.go index 72e9e3a..3e7d070 100644 --- a/internal/service/reader/reader_content_cache_test.go +++ b/internal/service/reader/reader_content_cache_test.go @@ -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) } diff --git a/internal/service/reader/search_page_test.go b/internal/service/reader/search_page_test.go index b8d22ae..661d137 100644 --- a/internal/service/reader/search_page_test.go +++ b/internal/service/reader/search_page_test.go @@ -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) } diff --git a/internal/service/reader/search_query_test.go b/internal/service/reader/search_query_test.go index 84069e1..357a986 100644 --- a/internal/service/reader/search_query_test.go +++ b/internal/service/reader/search_query_test.go @@ -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 != "宠魅" { diff --git a/internal/service/reader/search_scope_test.go b/internal/service/reader/search_scope_test.go index 534d91e..092f3aa 100644 --- a/internal/service/reader/search_scope_test.go +++ b/internal/service/reader/search_scope_test.go @@ -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) } diff --git a/internal/service/reader/source_import_test.go b/internal/service/reader/source_import_test.go index 7ca4937..16269a5 100644 --- a/internal/service/reader/source_import_test.go +++ b/internal/service/reader/source_import_test.go @@ -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("一条都没导入时应当返回错误") } diff --git a/internal/service/reader/source_per_user_test.go b/internal/service/reader/source_per_user_test.go new file mode 100644 index 0000000..bba5385 --- /dev/null +++ b/internal/service/reader/source_per_user_test.go @@ -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)) + } +} diff --git a/internal/service/reader/switch_origin_test.go b/internal/service/reader/switch_origin_test.go index bddc5bd..ecc2391 100644 --- a/internal/service/reader/switch_origin_test.go +++ b/internal/service/reader/switch_origin_test.go @@ -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) }