mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 15:56:37 +08:00
优化
This commit is contained in:
@@ -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。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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"`
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) == "" {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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("书源不存在或已被删除")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 != "宠魅" {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user