This commit is contained in:
truewhile
2026-10-10 12:00:33 +08:00
parent 4ed2edab5b
commit 42703c6760
22 changed files with 640 additions and 137 deletions
+11
View File
@@ -126,11 +126,22 @@ web/src/
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 | | 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.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/<bookID>.<txt\|epub>` + 解析目录入 `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.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `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.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.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 行为对齐,坏源不再拖死服务 | | 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、备份导出 | 完整体验 | | P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核 ### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。 前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
+1
View File
@@ -36,6 +36,7 @@
- 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。 - 「更新目录」(对应 legado `menu_update_toc`):`POST /reader/shelf/refresh-toc` 并发重抓书架内网络书籍的目录,覆盖章节缓存;末章标题变化时刷新 `latest_chapter_time`,用于「最近更新」排序与更新时间展示。本地书籍与无书源信息的书跳过。返回 `total/updated/unchanged/failed` 四档——抓到但章数没变(完结书)记 `unchanged`「已是最新」,只有抓取或写入真的失败才记 `failed`,避免把正常结果报成「更新失败」;失败会打 Warn 日志(含书名与书源)。
- 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。 - 未实现(与 legado 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。 - 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)** **书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。 - 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
@@ -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)
}
}
+109
View File
@@ -13,9 +13,19 @@ func AutoMigrate(db *gorm.DB) error {
if err := dedupePlaybackHistories(db); err != nil { if err := dedupePlaybackHistories(db); err != nil {
return err return err
} }
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil { if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err return err
} }
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil { if err := ensurePostgresColumnCompatibility(db); err != nil {
return err return err
} }
@@ -42,6 +52,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").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 // dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique // 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 // index can be created on existing databases. Keeps the most recent row per
+18 -9
View File
@@ -90,7 +90,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc { func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -108,7 +109,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -126,7 +128,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"}) c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -136,7 +139,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc { func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -153,7 +157,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -318,7 +323,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc { func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) { 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()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -335,7 +341,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -352,7 +359,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
} }
@@ -373,7 +381,8 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return 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 { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
+8 -1
View File
@@ -8,12 +8,19 @@ import (
) )
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。 // ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct { type ReaderBookSource struct {
Base 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"` Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"` GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频 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:"-"` RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"` Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"` EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
+57 -12
View File
@@ -15,10 +15,11 @@ type ReaderRepository struct {
db *gorm.DB db *gorm.DB
} }
// ListSources 书源列表(按 customOrder 排序)。 // ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) { func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource 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 return out, err
} }
@@ -31,8 +32,31 @@ func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.Rea
return &out, nil return &out, nil
} }
// GetSourceByURL 按书源 URL 取书源(导入去重用)。 // GetSourceForUser 按 ID 取书源并校验归属用户。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) { 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 var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil { if err != nil {
@@ -41,6 +65,13 @@ func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string)
return &out, nil 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 新增书源。 // CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error { func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error return r.db.WithContext(ctx).Create(src).Error
@@ -52,20 +83,34 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
} }
// DeleteSource 删除书源(连带清理其会话状态)。 // DeleteSource 删除书源(连带清理其会话状态)。
//
// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行——
// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的
// 正常操作。书源本身是可重新导入的数据,不需要软删保留。
//
// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何
// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error { func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{} src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" { if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引: var others int64
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的 if err := tx.Model(&model.ReaderBookSource{}).
// First(默认排除软删行)查不到、Create 就会撞唯一约束, Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil {
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err 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
}) })
} }
+1 -1
View File
@@ -269,7 +269,7 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc
if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" { if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" {
return headers 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 var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil { if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra { for k, v := range extra {
+2 -2
View File
@@ -450,7 +450,7 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL st
} }
// 书源级请求头(含 Referer / UA),与阅读请求保持一致 // 书源级请求头(含 Referer / UA),与阅读请求保持一致
if s.repo != nil { 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 var headers map[string]any
if json.Unmarshal([]byte(src.Header), &headers) == nil { if json.Unmarshal([]byte(src.Header), &headers) == nil {
for k, v := range headers { for k, v := range headers {
@@ -557,7 +557,7 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
} }
} }
if s.repo != nil { 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 var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil { if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra { for k, v := range extra {
+1 -1
View File
@@ -444,7 +444,7 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
return nil, fmt.Errorf("书籍不存在或已移出书架") return nil, fmt.Errorf("书籍不存在或已移出书架")
} }
sourceID := "" 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 sourceID = src.ID
} }
if strings.TrimSpace(title) == "" { if strings.TrimSpace(title) == "" {
+3 -3
View File
@@ -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"}`, `"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, 1,
) )
if _, err := svc.ImportSources(ctx, raw); err != nil { if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err) t.Fatalf("更新书源失败: %v", err)
} }
rule := svc.ImageDecodeRule(ctx, book) 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')"}`, `"ruleContent": {"content": "id.content@textNodes", "imageDecode": "throw new Error('bad rule')"}`,
1, 1,
) )
if _, err := svc.ImportSources(ctx, raw); err != nil { if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err) t.Fatalf("更新书源失败: %v", err)
} }
data := []byte("still-an-image") 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",`, `"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, 1,
) )
if _, err := svc.ImportSources(ctx, raw); err != nil { if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err) t.Fatalf("更新书源失败: %v", err)
} }
if !svc.SourceHasCoverDecode(srv.URL) { if !svc.SourceHasCoverDecode(srv.URL) {
+8 -8
View File
@@ -73,7 +73,7 @@ type LoginResult struct {
// GetSourceLogin 返回书源登录界面描述与当前登录状态。 // GetSourceLogin 返回书源登录界面描述与当前登录状态。
func (s *ReaderService) GetSourceLogin(ctx context.Context, userID, sourceID string) (*SourceLoginInfo, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -142,7 +142,7 @@ func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource,
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。 // fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。 // action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, action string, fields map[string]string) (*LoginResult, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -206,11 +206,11 @@ func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, ac
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。 // SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
// 保存后书源 JS 的 getVariable() 即可读到。 // 保存后书源 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)) { if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
return fmt.Errorf("变量必须是合法 JSON") return fmt.Errorf("变量必须是合法 JSON")
} }
src, bs, err := s.loadSource(ctx, sourceID) src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -222,8 +222,8 @@ func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variabl
} }
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。 // SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error { func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, userID, sourceID string, fields map[string]string) error {
src, bs, err := s.loadSource(ctx, sourceID) src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil { if err != nil {
return err return err
} }
@@ -239,8 +239,8 @@ func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string,
} }
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。 // ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error { func (s *ReaderService) ClearSourceLogin(ctx context.Context, userID, sourceID string) error {
src, bs, err := s.loadSource(ctx, sourceID) src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil { if err != nil {
return err return err
} }
+19 -19
View File
@@ -22,7 +22,7 @@ import (
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。 // 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
// readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。 // readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。
const readerTestUserID = "test-user" const readerTestUserID = "u1"
// loginTestServer 模拟一个需要登录的书源站点: // loginTestServer 模拟一个需要登录的书源站点:
// - POST /login_api 校验账号密码并下发会话 Cookie // - POST /login_api 校验账号密码并下发会话 Cookie
@@ -129,10 +129,10 @@ func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
// prepareLoginSource 导入测试书源并返回其 ID。 // prepareLoginSource 导入测试书源并返回其 ID。
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string { func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
t.Helper() t.Helper()
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil { if _, err := svc.ImportSources(t.Context(), "u1", sourceJSON); err != nil {
t.Fatal(err) t.Fatal(err)
} }
srcs, err := svc.ListSources(t.Context()) srcs, err := svc.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 { if err != nil || len(srcs) == 0 {
t.Fatalf("导入后应能读到书源: %v", err) t.Fatalf("导入后应能读到书源: %v", err)
} }
@@ -151,14 +151,14 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) 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) t.Fatal(err)
} else if len(books) != 0 { } else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books) 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", "邮箱": "user@example.com", "密码": "pw123456",
}) })
if err != nil { if err != nil {
@@ -176,7 +176,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
// ── 登录态应落库(换一个 service 实例仍可读到)── // ── 登录态应落库(换一个 service 实例仍可读到)──
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos) svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
info, err := svc2.GetSourceLogin(ctx, readerTestUserID, sourceID) info, err := svc2.GetSourceLogin(ctx, "u1", sourceID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
} }
// ── 登录后搜索:应携带 Cookie 并成功 ── // ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员", nil, 1) books, skipped, err := svc.Search(ctx, "u1", "会员", nil, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -200,17 +200,17 @@ func TestSourceLoginEndToEnd(t *testing.T) {
} }
// ── 登出:Cookie 清除,搜索重新未登录 ── // ── 登出:Cookie 清除,搜索重新未登录 ──
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil { if err := svc.ClearSourceLogin(ctx, "u1", sourceID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
info, err = svc.GetSourceLogin(ctx, readerTestUserID, sourceID) info, err = svc.GetSourceLogin(ctx, "u1", sourceID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if info.LoggedIn || len(info.Cookies) != 0 { if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info) 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) t.Fatal(err)
} else if len(books) != 0 { } else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books) t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -224,7 +224,7 @@ func TestSourceLoginWrongPassword(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) 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", "邮箱": "user@example.com", "密码": "wrong",
}) })
if err != nil { if err != nil {
@@ -248,7 +248,7 @@ func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -280,7 +280,7 @@ func TestSourceStateEncryptedAtRest(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) 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", "邮箱": "user@example.com", "密码": "pw123456",
}); err != nil { }); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -313,10 +313,10 @@ func TestSourceVariableRoundTrip(t *testing.T) {
ctx := t.Context() ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL)) 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) t.Fatal(err)
} }
info, err := svc.GetSourceLogin(ctx, readerTestUserID, sourceID) info, err := svc.GetSourceLogin(ctx, "u1", sourceID)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -329,7 +329,7 @@ func TestSourceVariableRoundTrip(t *testing.T) {
} }
// 非法 JSON 应被拒绝 // 非法 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 变量应被拒绝") t.Fatal("非法 JSON 变量应被拒绝")
} }
} }
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}` }`
sourceID := prepareLoginSource(t, svc, srcJSON) 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) t.Fatal(err)
} }
// 自动捕获被关闭:不应出现 auto=from-response // 自动捕获被关闭:不应出现 auto=from-response
@@ -386,10 +386,10 @@ func TestEnabledCookieJarGating(t *testing.T) {
defer srv2.Close() defer srv2.Close()
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL) srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源 // prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil { if _, err := svc.ImportSources(ctx, "u1", srcJSONOn); err != nil {
t.Fatal(err) 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) t.Fatal(err)
} }
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") { if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
+51 -29
View File
@@ -123,7 +123,7 @@ func (s *ReaderService) SourceHasCoverDecode(origin string) bool {
// ImportSources 导入书源:支持 JSON 数组 / 单对象 / Base64 / 网络URL。 // 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) text = strings.TrimSpace(text)
// 去 UTF-8 BOM(Windows 记事本导出的书源文件常见),否则 URL 检测和 JSON 解析都会失败 // 去 UTF-8 BOM(Windows 记事本导出的书源文件常见),否则 URL 检测和 JSON 解析都会失败
text = strings.TrimPrefix(text, "\uFEFF") 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)) != "") s.markSourceCoverDecode(bs.BookSourceURL, strings.TrimSpace(SPtr(bs.CoverDecodeJs)) != "")
// 已存在则更新,否则新建(按书源 URL 去重) // 已存在则更新,否则新建(按「用户 + 书源 URL」去重:书源按用户独立)
existing, err := s.repo.GetSourceByURL(ctx, bs.BookSourceURL) existing, err := s.repo.GetSourceByURL(ctx, userID, bs.BookSourceURL)
now := time.Now() now := time.Now()
record := &model.ReaderBookSource{ record := &model.ReaderBookSource{
UserID: userID,
Name: bs.BookSourceName, Name: bs.BookSourceName,
GroupName: strings.TrimSpace(SPtr(bs.BookSourceGroup)), GroupName: strings.TrimSpace(SPtr(bs.BookSourceGroup)),
Type: bs.Type(), Type: bs.Type(),
@@ -275,9 +276,9 @@ func ParseSourcePayload(text string) []string {
return out return out
} }
// ListSources 书源列表(标注是否支持登录,供前端决定是否显示登录入口)。 // ListSources 当前用户的书源列表(标注是否支持登录,供前端决定是否显示登录入口)。
func (s *ReaderService) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) { func (s *ReaderService) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
sources, err := s.repo.ListSources(ctx) sources, err := s.repo.ListSources(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -311,9 +312,9 @@ func rawSourceHasLogin(rawJSON string) bool {
return strings.TrimSpace(SPtr(probe.LoginURL)) != "" || strings.TrimSpace(SPtr(probe.LoginUI)) != "" return strings.TrimSpace(SPtr(probe.LoginURL)) != "" || strings.TrimSpace(SPtr(probe.LoginUI)) != ""
} }
// UpdateSourceEnabled 启停书源。 // UpdateSourceEnabled 启停书源(限当前用户自己的书源)。
func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, id string, enabled bool) error { func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, userID, id string, enabled bool) error {
src, err := s.repo.GetSource(ctx, id) src, err := s.repo.GetSourceForUser(ctx, userID, id)
if err != nil { if err != nil {
return err return err
} }
@@ -321,8 +322,11 @@ func (s *ReaderService) UpdateSourceEnabled(ctx context.Context, id string, enab
return s.repo.UpdateSource(ctx, src) return s.repo.UpdateSource(ctx, src)
} }
// DeleteSource 删除书源。 // DeleteSource 删除书源(限当前用户自己的书源)。
func (s *ReaderService) DeleteSource(ctx context.Context, id string) error { 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) return s.repo.DeleteSource(ctx, id)
} }
@@ -790,7 +794,6 @@ func (s *ReaderService) readerFileCacheDir() string {
return "" return ""
} }
// srcID 书源记录 ID(登录界面待办按书源隔离)。 // srcID 书源记录 ID(登录界面待办按书源隔离)。
func (sess *sourceSession) srcID() string { func (sess *sourceSession) srcID() string {
if sess.src != nil { if sess.src != nil {
@@ -917,11 +920,11 @@ type searchHit struct {
// page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发, // page 是页码(从 1 开始,对应书源 searchUrl 里的 {{page}}):搜索按页下发,
// 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码; // 前端滚到底再请求下一页并做增量合并。搜索范围内的所有源共用同一个页码;
// 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 { if page < 1 {
page = 1 page = 1
} }
sources, err := s.repo.ListSources(ctx) sources, err := s.repo.ListSources(ctx, userID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
@@ -1867,7 +1870,7 @@ func (s *ReaderService) FetchMediaWithOptions(ctx context.Context, book *model.R
helper.StripAcceptEncoding(httpReq.Header) helper.StripAcceptEncoding(httpReq.Header)
// 书源级请求头 // 书源级请求头
if s.repo != nil { 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 var headers map[string]any
if json.Unmarshal([]byte(found.Header), &headers) == nil { if json.Unmarshal([]byte(found.Header), &headers) == nil {
for k, v := range headers { for k, v := range headers {
@@ -2406,7 +2409,7 @@ func (s *ReaderService) DecodeImageBytes(ctx context.Context, book *model.Reader
if js == "" { if js == "" {
return data, nil return data, nil
} }
srcModel, err := s.repo.GetSourceByURL(ctx, book.Origin) srcModel, err := s.repo.GetSourceAnyByURL(ctx, book.Origin)
if err != nil { if err != nil {
return data, nil return data, nil
} }
@@ -2467,7 +2470,7 @@ func (s *ReaderService) fetchSourceResource(ctx context.Context, sourceURL, rawU
if err != nil || bs == nil { if err != nil || bs == nil {
return nil, fmt.Errorf("书源不存在") return nil, fmt.Errorf("书源不存在")
} }
srcModel, err := s.repo.GetSourceByURL(ctx, sourceURL) srcModel, err := s.repo.GetSourceAnyByURL(ctx, sourceURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -2605,8 +2608,10 @@ func (s *ReaderService) SwitchOrigin(ctx context.Context, userID, bookID string,
return book, nil return book, nil
} }
// ClearBookOriginCacheIfUnreferenced 清理 (origin, bookURL) 的正文缓存, // ClearBookOriginCacheIfUnreferenced 清理某本网络书的正文缓存,
// 但仅当没有其他书架记录仍在引用它时(同一本书可能被多个用户收藏)。 // 但仅当没有其他书架记录仍在引用它时(同一本书可能被多个用户收藏)。
//
// 缓存按「书源行 + 书本地址」组织:换源后旧地址的缓存不会再命中,这里负责回收空间。
func (s *ReaderService) ClearBookOriginCacheIfUnreferenced(ctx context.Context, origin, bookURL, excludeBookID string) { func (s *ReaderService) ClearBookOriginCacheIfUnreferenced(ctx context.Context, origin, bookURL, excludeBookID string) {
if s == nil || s.repo == nil || origin == "" || bookURL == "" { if s == nil || s.repo == nil || origin == "" || bookURL == "" {
return 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 取书(媒体代理等使用)。 // GetBook 按 ID 取书(媒体代理等使用)。
@@ -3134,13 +3143,13 @@ func deepCopyChapterContent(in *ChapterContent) *ChapterContent {
// ContentBatchItem 批量取正文的单章结果。 // ContentBatchItem 批量取正文的单章结果。
type ContentBatchItem struct { type ContentBatchItem struct {
ChapterIndex int `json:"chapter_index"` ChapterIndex int `json:"chapter_index"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
Content string `json:"content,omitempty"` Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"` Tracks []string `json:"tracks,omitempty"`
Images []string `json:"images,omitempty"` Images []string `json:"images,omitempty"`
ImageStyle string `json:"image_style,omitempty"` ImageStyle string `json:"image_style,omitempty"`
IsHLS bool `json:"hls,omitempty"` IsHLS bool `json:"hls,omitempty"`
Comments []ContentComment `json:"comments,omitempty"` Comments []ContentComment `json:"comments,omitempty"`
// Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。 // Cached 为 true 表示这次由持久缓存直接命中(未访问书源)。
Cached bool `json:"cached"` Cached bool `json:"cached"`
@@ -3418,8 +3427,8 @@ func (s *ReaderService) SmokeSource(ctx context.Context, raw string, key string)
} }
// Debug 书源调试接口:返回逐条日志字符串(前端展示用)。 // Debug 书源调试接口:返回逐条日志字符串(前端展示用)。
func (s *ReaderService) Debug(ctx context.Context, sourceID, key string) ([]string, error) { func (s *ReaderService) Debug(ctx context.Context, userID, sourceID, key string) ([]string, error) {
src, bs, err := s.loadSource(ctx, sourceID) src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -3448,6 +3457,19 @@ func (s *ReaderService) loadSource(ctx context.Context, sourceID string) (*model
return src, bs, nil 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, // loadSourceFlexible 按 ID 或 URL 加载书源(书架上只存 origin URL,
// 前端阅读链路用 source_url 定位书源)。 // 前端阅读链路用 source_url 定位书源)。
func (s *ReaderService) loadSourceFlexible(ctx context.Context, sourceID, sourceURL string) (*model.ReaderBookSource, *BookSource, error) { 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 == "" { if sourceURL == "" {
return nil, nil, fmt.Errorf("缺少书源标识(source_id 或 source_url)") 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 { if err != nil {
return nil, nil, fmt.Errorf("书源不存在或已被删除") return nil, nil, fmt.Errorf("书源不存在或已被删除")
} }
+68 -27
View File
@@ -21,11 +21,14 @@ import (
// //
// 设计要点: // 设计要点:
// - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后, // - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后,
// 用户替换规则、签名代理改写之前)。因此同一本书多用户共享同一份缓存, // 用户替换规则、签名代理改写之前)。因此缓存里不含任何用户维度的处理结果,
// 用户替换规则在读出后逐请求应用,规则改动即时生效。 // 用户替换规则在读出后逐请求应用,规则改动即时生效。
// - 索引落库(ReaderContentCache),内容落盘(cache_dir/reader-content/<origin>/<bookKey>/<key>.json)。 // - 缓存键按书源行组织(书源按用户独立,见 contentBookKey 的说明),
// - 缓存键只包含书源身份、章节身份、内容类型、书源指纹与格式版本:不含书籍行 ID // 不同用户即使持有同一 URL 的书源也各自一份缓存。
// (目录刷新会重建行)、不含用户(用户维度在读出后处理)。 // - 索引落库(ReaderContentCache),内容落盘
// (cache_dir/reader-content/<sourceKey>/<bookKey>/<chapterKey>.json)。
// - 缓存键只包含书源行、章节身份、书源指纹与格式版本:不含书籍行 ID
// (目录刷新会重建行,见 RemapContentCacheOnTocChange)。
// readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记) // readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记)
// 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。 // 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。
@@ -60,10 +63,19 @@ type contentFlight struct {
// ─── 键与身份 ────────────────────────────────────────────────────────────── // ─── 键与身份 ──────────────────────────────────────────────────────────────
// contentBookKey 书源身份哈希:origin + 书本地址。 // contentBookKey 书源身份哈希:书源行 ID + 书本地址。
// 用「文件地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞在一起。 //
func contentBookKey(origin, bookURL string) string { // 用书源行 ID 而不是 origin(书源 URL):书源按用户独立,不同用户可以持有同一
sum := sha256.Sum256([]byte(origin + "\x00" + strings.TrimSpace(bookURL))) // 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]) return hex.EncodeToString(sum[:16])
} }
@@ -116,24 +128,23 @@ func (s *ReaderService) readerContentDir() string {
return filepath.Join(base, "reader-content") return filepath.Join(base, "reader-content")
} }
// contentFilePath 单条缓存的磁盘路径:<root>/<originHash>/<bookKey>/<chapterKey>.json。 // contentFilePath 单条缓存的磁盘路径:<root>/<sourceKey>/<bookKey>/<chapterKey>.json。
// chapterKey 已是 hex 哈希,不含路径分隔符。 // chapterKey 已是 hex 哈希,不含路径分隔符。
func (s *ReaderService) contentFilePath(origin, bookURL, chapterKey string) string { func (s *ReaderService) contentFilePath(sourceID, bookURL, chapterKey string) string {
root := s.readerContentDir() root := s.readerContentDir()
if root == "" { if root == "" {
return "" return ""
} }
originHash := contentBookKey(origin, "") return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL), chapterKey+".json")
return filepath.Join(root, originHash, contentBookKey(origin, bookURL), chapterKey+".json")
} }
// contentBookDir 某本书的缓存目录(整本清理用)。 // contentBookDir 某本书的缓存目录(整本清理用)。
func (s *ReaderService) contentBookDir(origin, bookURL string) string { func (s *ReaderService) contentBookDir(sourceID, bookURL string) string {
root := s.readerContentDir() root := s.readerContentDir()
if root == "" { if root == "" {
return "" return ""
} }
return filepath.Join(root, contentBookKey(origin, ""), contentBookKey(origin, bookURL)) return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL))
} }
// writeContentFile 原子写入缓存文件(临时文件 + rename)。 // 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。 // loadCachedContent 读取一章节的缓存:命中返回内容与 true。
// //
// contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时 // contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时
@@ -185,7 +212,11 @@ func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.Reader
if s == nil || s.repo == nil || book == nil { if s == nil || s.repo == nil || book == nil {
return nil, false 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) chapterKey := contentChapterKey(book, ch)
row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType) row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType)
if err != nil || row == nil { if err != nil || row == nil {
@@ -205,7 +236,7 @@ func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.Reader
s.dropContentCacheRow(ctx, row) s.dropContentCacheRow(ctx, row)
return nil, false 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 -- 路径由服务端生成 raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
if err != nil { if err != nil {
s.dropContentCacheRow(ctx, row) s.dropContentCacheRow(ctx, row)
@@ -253,7 +284,11 @@ func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.Reader
if err != nil || len(raw) > readerContentMaxEntryBytes { if err != nil || len(raw) > readerContentMaxEntryBytes {
return 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 err := writeContentFile(path, raw); err != nil {
if s.log != nil { if s.log != nil {
s.log.Warn("reader: 写入正文缓存失败", zap.String("path", path), zap.Error(err)) 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() now := time.Now().Unix()
row := &model.ReaderContentCache{ row := &model.ReaderContentCache{
OriginHash: contentBookKey(book.Origin, ""), OriginHash: contentOriginKey(sourceID),
BookKey: contentBookKey(book.Origin, book.BookURL), BookKey: contentBookKey(sourceID, book.BookURL),
ChapterKey: contentChapterKey(book, ch), ChapterKey: contentChapterKey(book, ch),
ChapterIdentity: contentChapterIdentity(book, ch), ChapterIdentity: contentChapterIdentity(book, ch),
ChapterIndex: ch.Index, ChapterIndex: ch.Index,
@@ -318,17 +353,19 @@ func (s *ReaderService) pruneContentDirIfEmpty(dir string) {
} }
} }
// ClearContentCacheForBook 清理一本书的全部正文缓存(换源/移出书架时调用)。 // ClearContentCacheForBook 清理某书源行下一本书的全部正文缓存(换源/移出书架时调用)。
// 同源同书可能被多个用户收藏:仍被引用时不删(见 DeleteBook/ClearBookOrigin)。 //
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, origin, bookURL string) { // sourceID 是书源行 ID:书源按用户独立,缓存也随之按书源行隔离,所以删除
if s == nil || s.repo == nil || origin == "" || bookURL == "" { // 自己那份书源或换源不会影响其他用户的同名书源缓存。
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, sourceID, bookURL string) {
if s == nil || s.repo == nil || sourceID == "" || bookURL == "" {
return return
} }
bookKey := contentBookKey(origin, bookURL) bookKey := contentBookKey(sourceID, bookURL)
if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil { if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil {
return return
} }
if dir := s.contentBookDir(origin, bookURL); dir != "" { if dir := s.contentBookDir(sourceID, bookURL); dir != "" {
_ = os.RemoveAll(dir) _ = os.RemoveAll(dir)
s.pruneContentDirIfEmpty(filepath.Dir(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 { if s == nil || s.repo == nil || book == nil || len(oldChapters) == 0 || len(newChapters) == 0 {
return 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) rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 { if err != nil || len(rows) == 0 {
return return
@@ -445,7 +486,7 @@ func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *
continue // 身份未变,只更新序号 continue // 身份未变,只更新序号
} }
oldPath := s.contentPathFromRow(&row) oldPath := s.contentPathFromRow(&row)
newPath := s.contentFilePath(book.Origin, book.BookURL, newKey) newPath := s.contentFilePath(sourceID, book.BookURL, newKey)
if oldPath != "" && newPath != "" { if oldPath != "" && newPath != "" {
if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil { if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil {
// 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。 // 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。
@@ -144,8 +144,12 @@ func TestContentCacheHitAndPersist(t *testing.T) {
t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got) t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got)
} }
// 缓存文件确实落盘 // 缓存文件确实落盘(键按书源行 ID 组织)。
path := svc.contentFilePath(book.Origin, book.BookURL, contentChapterKey(book, model.ReaderChapter{ 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", Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html",
})) }))
if path == "" { if path == "" {
@@ -279,7 +283,7 @@ func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
} }
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。 // 重新导入同一书源(RawJSON 变化)→ 指纹变化。
updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1) 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) t.Fatalf("更新书源失败: %v", err)
} }
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil { 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 { if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
t.Fatalf("读取失败: %v", err) 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 { if _, err := os.Stat(dir); err != nil {
t.Fatalf("缓存目录应存在: %v", err) t.Fatalf("缓存目录应存在: %v", err)
} }
@@ -307,7 +312,7 @@ func TestContentCacheClearedOnRemoveBook(t *testing.T) {
if _, err := os.Stat(dir); !os.IsNotExist(err) { if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("移出书架后缓存目录应被清理,err=%v", 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) t.Fatalf("缓存索引应清空: rows=%d err=%v", len(rows), err)
} }
} }
@@ -318,10 +323,12 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
ctx := t.Context() ctx := t.Context()
ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"} 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"}, 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 { if err != nil {
t.Fatalf("音频缓存未写入: %v", err) t.Fatalf("音频缓存未写入: %v", err)
} }
@@ -333,10 +340,10 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
if err := svc.repo.UpsertContentCache(ctx, row); err != nil { if err := svc.repo.UpsertContentCache(ctx, row); err != nil {
t.Fatal(err) 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("过期缓存不应命中") 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("过期条目应被删除") t.Fatal("过期条目应被删除")
} }
} }
@@ -344,7 +351,7 @@ func TestContentCacheAudioShortTTL(t *testing.T) {
// mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。 // mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。
func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource { func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource {
t.Helper() t.Helper()
src, err := svc.repo.GetSourceByURL(context.Background(), sourceURL) src, err := svc.repo.GetSourceByURL(context.Background(), "u1", sourceURL)
if err != nil { if err != nil {
t.Fatalf("取书源失败: %v", err) t.Fatalf("取书源失败: %v", err)
} }
+2 -2
View File
@@ -57,7 +57,7 @@ func TestSearchUsesRequestedPage(t *testing.T) {
{0, "1"}, // 非法页码退回第 1 页 {0, "1"}, // 非法页码退回第 1 页
{-3, "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 { if err != nil {
t.Fatalf("page=%d 搜索失败: %v", tc.in, err) t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
} }
@@ -88,7 +88,7 @@ func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL)) _ = 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 { if err != nil {
t.Fatalf("搜索失败: %v", err) t.Fatalf("搜索失败: %v", err)
} }
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) _ = 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 { if err != nil {
t.Fatalf("搜索失败: %v", err) t.Fatalf("搜索失败: %v", err)
} }
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL)) _ = 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 { if err != nil {
t.Fatalf("搜索失败: %v", err) t.Fatalf("搜索失败: %v", err)
} }
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src) out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out)) _ = 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) t.Fatalf("搜索失败: %v", err)
} }
if gotQuery != "宠魅" { if gotQuery != "宠魅" {
+5 -5
View File
@@ -53,11 +53,11 @@ func TestSearchRespectsSourceScope(t *testing.T) {
scopeEchoSourceJSON(t, "源A", srvA.URL), scopeEchoSourceJSON(t, "源A", srvA.URL),
scopeEchoSourceJSON(t, "源B", srvB.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) t.Fatal(err)
} }
} }
sources, err := svc.ListSources(t.Context()) sources, err := svc.ListSources(t.Context(), "u1")
if err != nil { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -38,7 +38,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
ctx := t.Context() ctx := t.Context()
imported, err := svc.ImportSources(ctx, messySourceJSON) imported, err := svc.ImportSources(ctx, "u1", messySourceJSON)
if err != nil { if err != nil {
t.Fatalf("导入失败: %v", err) t.Fatalf("导入失败: %v", err)
} }
@@ -46,7 +46,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
t.Fatalf("导入数量 = %d,期望 1", imported) 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 { if err != nil || bs == nil {
t.Fatalf("按 URL 查不到导入的书源: %v", err) t.Fatalf("按 URL 查不到导入的书源: %v", err)
} }
@@ -139,7 +139,7 @@ func TestParseBookSourceRejectsGarbage(t *testing.T) {
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) { func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
svc, _ := newLoginTestService(t) svc, _ := newLoginTestService(t)
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`) _, err := svc.ImportSources(t.Context(), "u1", `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
if err == nil { if err == nil {
t.Fatal("一条都没导入时应当返回错误") 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 找到它(同包测试可能导入多个源)。 // importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。
func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string { func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string {
t.Helper() 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) t.Fatalf("导入书源失败: %v", err)
} }
srcs, err := svc.ListSources(t.Context()) srcs, err := svc.ListSources(t.Context(), "u1")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }