Compare commits

...

4 Commits

Author SHA1 Message Date
truewhile 42703c6760 优化 2026-10-10 12:00:33 +08:00
truewhile 4ed2edab5b 优化,阅读问题处理 2026-10-10 11:26:47 +08:00
truewhile 374ee28f01 添加点击2次全屏的功能 2026-10-10 09:54:01 +08:00
truewhile 3a6b8db0b3 添加全屏功能 2026-10-08 23:59:35 +08:00
53 changed files with 4803 additions and 234 deletions
+24 -1
View File
@@ -38,7 +38,7 @@ web/src/
components/reader/
```
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(正文/目录/搜索缓存,内存+Redis)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
@@ -126,8 +126,31 @@ web/src/
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<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.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
真实缺陷是另外两个并已修复:
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
## 8. 风险与对策
+5 -1
View File
@@ -34,7 +34,9 @@
- 排序六档,对齐 legado `AppConfig.getBookSortByGroupId`:最近阅读(dur_chapter_time,默认)/ 最近更新(latest_chapter_time)/ 综合 / 按书名 / 按作者 / 手动顺序(order)。顶栏有快捷排序下拉,弹窗里也可选。
- 显示项开关:未读章数徽标、更新时间。
- 「更新目录」(对应 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` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
@@ -103,6 +105,8 @@
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)**
+2
View File
@@ -52,6 +52,8 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+6
View File
@@ -62,6 +62,12 @@ func (c *Config) normalize() error {
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90
}
if c.Cache.ReaderContentMaxSizeMB < 0 {
c.Cache.ReaderContentMaxSizeMB = 0
}
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_media"
+4
View File
@@ -133,6 +133,10 @@ type CacheConfig struct {
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
}
type SearchConfig struct {
@@ -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 {
return err
}
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
@@ -42,6 +52,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per
+145 -17
View File
@@ -14,6 +14,7 @@ import (
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
readerrule "github.com/truewhile/MeBox/internal/service/reader/rule"
)
func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
@@ -77,6 +78,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
g.GET("/books/:id/chapters", readerListChaptersHandler(svc))
g.POST("/books/:id/chapters", readerReplaceChaptersHandler(svc))
g.GET("/books/:id/content", readerBookContentHandler(svc))
// 批量取正文(离线缓存协议,对应 legado CacheBook):返回每章内容与命中统计
g.POST("/books/:id/content-batch", readerBookContentBatchHandler(svc))
// 替换净化规则
g.GET("/replace-rules", readerListReplaceRulesHandler(svc))
@@ -87,7 +90,8 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
func readerListSourcesHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
sources, err := svc.Reader.ListSources(c.Request.Context())
userID := c.GetString(middleware.CtxUserID)
sources, err := svc.Reader.ListSources(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -105,7 +109,8 @@ func readerImportSourcesHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
imported, err := svc.Reader.ImportSources(c.Request.Context(), body.Text)
userID := c.GetString(middleware.CtxUserID)
imported, err := svc.Reader.ImportSources(c.Request.Context(), userID, body.Text)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -123,7 +128,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled 字段必填"})
return
}
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), c.Param("id"), *body.Enabled); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.UpdateSourceEnabled(c.Request.Context(), userID, c.Param("id"), *body.Enabled); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -133,7 +139,8 @@ func readerUpdateSourceHandler(svc *service.Container) gin.HandlerFunc {
func readerDeleteSourceHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.DeleteSource(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.DeleteSource(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -150,7 +157,8 @@ func readerDebugSourceHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
logs, err := svc.Reader.Debug(c.Request.Context(), c.Param("id"), body.Key)
userID := c.GetString(middleware.CtxUserID)
logs, err := svc.Reader.Debug(c.Request.Context(), userID, c.Param("id"), body.Key)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -315,7 +323,8 @@ func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
func readerSourceLogoutHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), c.Param("id")); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.ClearSourceLogin(c.Request.Context(), userID, c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -332,7 +341,8 @@ func readerSetSourceVariableHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceVariable(c.Request.Context(), c.Param("id"), body.Variable); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceVariable(c.Request.Context(), userID, c.Param("id"), body.Variable); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -349,7 +359,8 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), c.Param("id"), body.Fields); err != nil {
userID := c.GetString(middleware.CtxUserID)
if err := svc.Reader.SetSourceLoginInfo(c.Request.Context(), userID, c.Param("id"), body.Fields); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -370,7 +381,8 @@ func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
userID := c.GetString(middleware.CtxUserID)
books, skipped, err := svc.Reader.Search(c.Request.Context(), userID, body.Key, body.SourceIDs, body.Page)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -389,6 +401,10 @@ func readerBookInfoHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 声明了封面解密的书源:详情封面也要走解密代理(尚未入库,按书源 URL 签)。
if info != nil {
info.CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), "", c.Query("source_url"), info.CoverURL)
}
c.JSON(http.StatusOK, info)
}
}
@@ -435,6 +451,10 @@ func readerListBooksHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 封面解密代理只在下发时改写:库里仍存原始地址,换源/换签名密钥后不会失效。
for i := range books {
books[i].CoverURL = svc.Reader.RewriteBookCover(c.Request.Context(), books[i].ID, books[i].Origin, books[i].CoverURL)
}
c.JSON(http.StatusOK, gin.H{"books": books})
}
}
@@ -819,12 +839,37 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅支持 http(s) 媒体地址"})
return
}
// 封面解密代理(d=cover):签名主体可能是书源 URL(搜索结果尚未入库)。
// 封面不读取书籍正文,因此不需要书籍记录;统一整段读取后解密下发。
if c.Query("d") == "cover" {
resp, fetchErr := svc.Reader.FetchCover(c.Request.Context(), bookID, rawURL)
if fetchErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面拉取失败: " + fetchErr.Error()})
return
}
defer resp.Body.Close()
data, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(data) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "封面过大或读取失败"})
return
}
decoded := svc.Reader.DecodeCoverBytes(c.Request.Context(), bookID, rawURL, data)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
book, err := svc.Reader.GetBook(c.Request.Context(), bookID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "书籍不存在"})
return
}
resp, err := svc.Reader.FetchMedia(c.Request.Context(), book, rawURL, c.GetHeader("Range"))
// 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用,
// 代理地址里只保留不带选项的地址(见 ProxyURL)。
rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL)
resp, err := svc.Reader.FetchMediaWithOptions(c.Request.Context(), book, rawURL, c.GetHeader("Range"), mediaOptions.Headers)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "媒体拉取失败: " + err.Error()})
return
@@ -832,13 +877,51 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
isPlaylist := strings.Contains(ct, "mpegurl") || strings.Contains(ct, "m3u8") ||
strings.HasSuffix(strings.ToLower(rawURL), ".m3u8")
if isPlaylist {
// m3u8:改写分片/密钥地址为签名代理后返回,hls.js 无感续播
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(data))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
// 书源声明了 imageDecode 时,图片字节需要服务端二次解密。
// 这类响应必须整段读取后处理,不再支持 Range(解密后长度会变)。
if decodeJS := svc.Reader.ImageDecodeRule(c.Request.Context(), book); decodeJS != "" && !isMediaPlaylistRequest(resp) {
sniffed, readErr := io.ReadAll(io.LimitReader(resp.Body, readerImageDecodeMaxBytes+1))
if readErr != nil || len(sniffed) > readerImageDecodeMaxBytes {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片过大或读取失败"})
return
}
decoded, decErr := svc.Reader.DecodeImageBytes(c.Request.Context(), book, rawURL, false, sniffed)
if decErr != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "图片解密失败: " + decErr.Error()})
return
}
contentType := ct
if contentType == "" {
contentType = http.DetectContentType(decoded)
}
c.Data(resp.StatusCode, contentType, decoded)
return
}
// m3u8 判定不能只看 Content-Type 或 URL 后缀:上游经常把播放列表标成
// application/octet-stream,或者地址是 /index.m3u8?token=...(后缀判断不命中)。
// 先读一小段用 #EXTM3U 标记确认;不是播放列表就把这段拼回响应体继续流式透传。
//
// 只嗅探 200 的 GET:206 的体是二进制分片、HEAD 没有体,都不需要判断。
if c.Request.Method == http.MethodGet && resp.StatusCode == http.StatusOK {
head, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if readErr == nil && reader.BodyIsPlaylist(head, ct) {
rest, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
rewritten := svc.Reader.RewritePlaylist(bookID, rawURL, string(head)+string(rest))
c.Data(http.StatusOK, "application/vnd.apple.mpegurl", []byte(rewritten))
return
}
// 不是播放列表:透传时 Content-Length 可能因为已经读过一部分而失配,
// 直接省略交给 net/http 按 chunked 处理,避免少写/多写导致截断。
for _, h := range []string{"Content-Type", "Content-Range", "Accept-Ranges"} {
if v := resp.Header.Get(h); v != "" {
c.Header(h, v)
}
}
c.Status(resp.StatusCode)
if len(head) > 0 {
_, _ = c.Writer.Write(head)
}
_, _ = io.Copy(c.Writer, resp.Body)
return
}
// 流式透传(含 206 Partial Content,支持音频拖动进度)
@@ -852,6 +935,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc {
}
}
// readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。
const readerImageDecodeMaxBytes = 32 << 20
// isMediaPlaylistRequest 判断响应是否可能已是 HLS 播放列表。
// 播放列表太小、不该走图片解密分支(书源的 imageDecode 只会用于图片)。
func isMediaPlaylistRequest(resp *http.Response) bool {
ct := strings.ToLower(resp.Header.Get("Content-Type"))
return strings.Contains(ct, "mpegurl")
}
// readerBodyIsPlaylist 判断上游响应体是否是 HLS 播放列表。
// 以 #EXTM3U 标记为准:Content-Type 只是辅助(上游常标成 octet-stream)。
func readerBodyIsPlaylist(head []byte, contentType string) bool {
return reader.BodyIsPlaylist(head, contentType)
}
// readerLocalAssetHandler 本地书籍内嵌资源(EPUB 图片等):
// 鉴权走 HMAC 签名(<img src> 带不上 JWT),与 /reader/media 同一套做法。
func readerLocalAssetHandler(svc *service.Container) gin.HandlerFunc {
@@ -940,3 +1039,32 @@ func readerBookContentHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusOK, content)
}
}
// readerBookContentBatchHandler 批量取正文(对应 legado CacheBook / getContentBatch)。
//
// 请求 {chapter_indexes:[...], apply_replace:bool}:单章失败不中断整批,
// 返回每章内容与 hit/miss/failed 统计,供前端窗口预取与后续离线缓存。
func readerBookContentBatchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
ChapterIndexes []int `json:"chapter_indexes" binding:"required"`
// ApplyReplace 是否在服务端应用用户替换净化规则(默认 false,与单章接口一致)。
ApplyReplace bool `json:"apply_replace"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "chapter_indexes 不能为空"})
return
}
if len(body.ChapterIndexes) > 64 {
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多请求 64 章"})
return
}
userID := c.GetString(middleware.CtxUserID)
result, err := svc.Reader.GetContentBatch(c.Request.Context(), userID, c.Param("id"), body.ChapterIndexes, body.ApplyReplace)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}
}
+22 -3
View File
@@ -17,6 +17,13 @@ import (
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
// maxDecompressedBody 解压后的响应体上限。
//
// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以
// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底,
// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。
const maxDecompressedBody = 32 << 20
// DecompressBody 兜底解压响应体(gzip / deflate)。
//
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
@@ -37,17 +44,20 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data
}
defer r.Close()
if out, err := io.ReadAll(r); err == nil {
// 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解,
// 是压缩炸弹的常见放大手段。
r.Multistream(false)
if out, ok := readLimited(r); ok {
return out
}
case "deflate":
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
if out, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))); err == nil {
if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok {
return out
}
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
defer zr.Close()
if out, err := io.ReadAll(zr); err == nil {
if out, ok := readLimited(zr); ok {
return out
}
}
@@ -55,6 +65,15 @@ func DecompressBody(resp *http.Response, data []byte) []byte {
return data
}
// readLimited 读取解压流,超过上限返回 ok=false。
func readLimited(r io.Reader) ([]byte, bool) {
out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1))
if err != nil || len(out) > maxDecompressedBody {
return nil, false
}
return out, true
}
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
//
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
+1
View File
@@ -65,6 +65,7 @@ func AllModels() []interface{} {
&ReaderSourceState{},
&ReaderBook{},
&ReaderChapter{},
&ReaderContentCache{},
&ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
+47 -1
View File
@@ -8,12 +8,19 @@ import (
)
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct {
Base
// UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」,
// 只在没有任何用户引用时才会出现。
UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"`
Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
SourceURL string `gorm:"type:varchar(512);index" json:"source_url"`
SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"`
RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
@@ -98,6 +105,45 @@ type ReaderChapter struct {
Tag string `gorm:"type:varchar(255)" json:"tag"`
}
// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。
//
// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex
// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后
// 逐请求应用,规则改动即时生效。
//
// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节
// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新
// 之后仍然能按同一身份命中或迁移。
type ReaderContentCache struct {
Base
// OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。
OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"`
// BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。
BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"`
// ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。
ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"`
// ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。
ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"`
// ChapterIndex 保存时的章节序号(remap 时更新)。
ChapterIndex int `json:"chapter_index"`
// ContentType text / audio / image。
ContentType string `gorm:"type:varchar(16)" json:"content_type"`
// SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。
SourceHash string `gorm:"type:varchar(64)" json:"source_hash"`
// FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。
FormatVersion int `json:"format_version"`
// SizeBytes 载荷字节数(容量统计用)。
SizeBytes int64 `json:"size_bytes"`
// AssetCount 音频轨/图片张数(清单类内容的完整性统计)。
AssetCount int `json:"asset_count"`
// ExpiresAt 过期时间(unix 秒);0 表示不过期。
ExpiresAt int64 `json:"expires_at"`
// LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。
LastAccessAt int64 `json:"last_access_at"`
// Hits 命中次数(诊断用)。
Hits int `json:"hits"`
}
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
type ReaderReplaceRule struct {
Base
+180 -12
View File
@@ -15,10 +15,11 @@ type ReaderRepository struct {
db *gorm.DB
}
// ListSources 书源列表(按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context) ([]model.ReaderBookSource, error) {
// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Order("custom_order ASC, updated_at DESC").Find(&out).Error
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("custom_order ASC, updated_at DESC").Find(&out).Error
return out, err
}
@@ -31,8 +32,31 @@ func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.Rea
return &out, nil
}
// GetSourceByURL 按书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
// GetSourceForUser 按 ID 取书源并校验归属用户。
func (r *ReaderRepository) GetSourceForUser(ctx context.Context, userID, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceByURL 按用户 + 书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, userID, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND source_url = ?", userID, sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetSourceAnyByURL 按书源 URL 取任意一份副本(不限定用户)。
//
// 媒体代理、目录刷新等路径只拿得到书籍的 origin(书源 URL)而没有用户上下文;
// 请求目标由 URL 决定,任一副本都够用。多个用户各自改过规则/header 时,
// 取到哪一份只影响「用谁的默认 header」,不影响书籍本身的定位。
func (r *ReaderRepository) GetSourceAnyByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
@@ -41,6 +65,13 @@ func (r *ReaderRepository) GetSourceByURL(ctx context.Context, sourceURL string)
return &out, nil
}
// ListSourceRowsByURL 取某书源 URL 的全部副本(判断是否仍有其他用户引用)。
func (r *ReaderRepository) ListSourceRowsByURL(ctx context.Context, sourceURL string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Where("source_url = ?", sourceURL).Find(&out).Error
return out, err
}
// CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error
@@ -52,20 +83,34 @@ func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBo
}
// DeleteSource 删除书源(连带清理其会话状态)。
//
// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行——
// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的
// 正常操作。书源本身是可重新导入的数据,不需要软删保留。
//
// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何
// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
var others int64
if err := tx.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil {
return err
}
if others == 0 {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
}
return tx.Delete(&model.ReaderBookSource{}, "id = ?", id).Error
return tx.Unscoped().Delete(&model.ReaderBookSource{}, "id = ?", id).Error
})
}
@@ -131,6 +176,15 @@ func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bo
return &out, nil
}
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
return out, err
}
// CreateBook / UpdateBook / DeleteBook。
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Create(b).Error
@@ -163,6 +217,17 @@ func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, c
})
}
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
return err
}
return tx.Save(book).Error
})
}
// ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter
@@ -170,6 +235,109 @@ func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]m
return out, err
}
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
var out model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
if contentType != "" {
q = q.Where("content_type = ?", contentType)
}
var out model.ReaderContentCache
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
return nil, err
}
return &out, nil
}
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
if err == nil && existing != nil {
existing.ChapterIdentity = row.ChapterIdentity
existing.ChapterIndex = row.ChapterIndex
existing.SourceHash = row.SourceHash
existing.FormatVersion = row.FormatVersion
existing.SizeBytes = row.SizeBytes
existing.AssetCount = row.AssetCount
existing.ExpiresAt = row.ExpiresAt
existing.LastAccessAt = row.LastAccessAt
return r.db.WithContext(ctx).Save(existing).Error
}
return r.db.WithContext(ctx).Create(row).Error
}
// TouchContentCache 记录一次命中(更新命中时间与次数)。
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Where("id = ?", id).
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
}
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
return out, err
}
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
rows, err := r.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return nil, err
}
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
}
// ListContentCacheExpired 按 TTL 取过期条目。
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("expires_at > 0 AND expires_at < ?", now).
Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
var row struct {
Count int64
Bytes int64
}
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
return row.Count, row.Bytes, err
}
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
// 没有目录缓存的书籍不会出现在返回结果里。
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, 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) == "" {
return headers
}
if src, err := s.repo.GetSourceByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
if src, err := s.repo.GetSourceAnyByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra {
+2 -2
View File
@@ -450,7 +450,7 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL st
}
// 书源级请求头(含 Referer / UA),与阅读请求保持一致
if s.repo != nil {
if src, findErr := s.repo.GetSourceByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(src.Header), &headers) == nil {
for k, v := range headers {
@@ -557,7 +557,7 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
}
}
if s.repo != nil {
if src, findErr := s.repo.GetSourceByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra {
+1 -1
View File
@@ -444,7 +444,7 @@ func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID,
return nil, fmt.Errorf("书籍不存在或已移出书架")
}
sourceID := ""
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
if src, findErr := s.repo.GetSourceAnyByURL(ctx, book.Origin); findErr == nil && src != nil {
sourceID = src.ID
}
if strings.TrimSpace(title) == "" {
@@ -0,0 +1,133 @@
package reader
import (
"strings"
"testing"
)
// 图片解密(coverDecodeJs / ruleContent.imageDecode)与 HLS 判定链路。
// 正文图片解密:书源规则把字节异或 0x55 后返回,服务端解密应还原原文。
func TestDecodeImageBytesRunsRuleJS(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
// 动态给书源加上 imageDecode 规则:XOR 0x55 还原。
raw := strings.Replace(
cacheTestSourceJSON(srv.URL),
`"ruleContent": {"content": "id.content@textNodes"}`,
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x55; } out"}`,
1,
)
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err)
}
rule := svc.ImageDecodeRule(ctx, book)
if rule == "" {
t.Fatal("imageDecode 规则未解析")
}
plain := []byte("PNG-PLAIN-BYTES")
encoded := make([]byte, len(plain))
for i, b := range plain {
encoded[i] = b ^ 0x55
}
decoded, err := svc.DecodeImageBytes(ctx, book, "https://img.example.com/a.png", false, encoded)
if err != nil {
t.Fatalf("解密失败: %v", err)
}
if string(decoded) != string(plain) {
t.Fatalf("解密结果 = %q,期望 %q", decoded, plain)
}
}
// 没有规则时零开销直通(返回原始字节)。
func TestDecodeImageBytesPassthroughWithoutRule(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
data := []byte("raw-image-bytes")
out, err := svc.DecodeImageBytes(t.Context(), book, srv.URL+"/a.png", false, data)
if err != nil {
t.Fatalf("直通路径不应报错: %v", err)
}
if string(out) != string(data) {
t.Fatalf("无规则时应原样返回: %q", out)
}
}
// 解密脚本抛错时退回原始字节(保证至少还能看到图)。
func TestDecodeImageBytesFallsBackOnBadRule(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
raw := strings.Replace(
cacheTestSourceJSON(srv.URL),
`"ruleContent": {"content": "id.content@textNodes"}`,
`"ruleContent": {"content": "id.content@textNodes", "imageDecode": "throw new Error('bad rule')"}`,
1,
)
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err)
}
data := []byte("still-an-image")
out, err := svc.DecodeImageBytes(ctx, book, srv.URL+"/a.png", false, data)
if err != nil {
t.Fatalf("坏规则应降级而不是报错: %v", err)
}
if string(out) != string(data) {
t.Fatalf("坏规则应返回原始字节: %q", out)
}
}
// 封面解密:按书源 URL(搜索结果尚未入库)执行 coverDecodeJs。
func TestDecodeCoverBytes(t *testing.T) {
svc, srv, _ := prepareCacheTestBook(t)
ctx := t.Context()
raw := strings.Replace(
cacheTestSourceJSON(srv.URL),
`"bookSourceType": 0,`,
`"bookSourceType": 0, "coverDecodeJs": "var src = new Uint8Array(result); var out = new Uint8Array(src.length); for (var i=0;i<src.length;i++){ out[i] = src[i] ^ 0x33; } out",`,
1,
)
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("更新书源失败: %v", err)
}
if !svc.SourceHasCoverDecode(srv.URL) {
t.Fatal("应识别出该源声明了封面解密")
}
plain := []byte("cover-bytes")
encoded := make([]byte, len(plain))
for i, b := range plain {
encoded[i] = b ^ 0x33
}
out := svc.DecodeCoverBytes(ctx, srv.URL, srv.URL+"/cover.jpg", encoded)
if string(out) != string(plain) {
t.Fatalf("封面解密结果 = %q,期望 %q", out, plain)
}
// 未声明解密的源:封面地址保持原样(不签代理)。
plainURL := svc.RewriteBookCover(ctx, "", srv.URL+"/x", "https://img.example.com/c.jpg")
if !strings.HasPrefix(plainURL, "https://img.example.com/") {
t.Fatalf("无解密规则时不应改写封面: %q", plainURL)
}
// 声明了解密的源:封面走签名代理。
signed := svc.RewriteBookCover(ctx, "", srv.URL, "https://img.example.com/c.jpg")
if !strings.Contains(signed, "/api/reader/media?") || !strings.Contains(signed, "d=cover") {
t.Fatalf("封面应走解密代理: %q", signed)
}
}
// HLS 播放列表嗅探:#EXTM3U 标记优先于 Content-Type。
func TestReaderBodyIsPlaylist(t *testing.T) {
playlist := []byte("#EXTM3U\n#EXT-X-VERSION:3\n")
if !BodyIsPlaylist(playlist, "application/octet-stream") {
t.Fatal("以 #EXTM3U 开头应判为播放列表")
}
if BodyIsPlaylist([]byte("\x89PNG\r\n"), "image/png") {
t.Fatal("二进制图片不应判为播放列表")
}
if !BodyIsPlaylist([]byte("\n#EXTM3U\n"), "application/vnd.apple.mpegurl") {
t.Fatal("带前置空行且 Content-Type 为 mpegurl 时应判为播放列表")
}
if BodyIsPlaylist(nil, "application/vnd.apple.mpegurl") {
t.Fatal("空体不应判为播放列表")
}
}
+55 -4
View File
@@ -143,6 +143,11 @@ func parseLocalBookPayload(name string, data []byte) (*localBookPayload, error)
return nil, fmt.Errorf("文件内容为空或无法解码")
}
out.ext, out.charset = ".txt", charset
// 章节区间是按「解码后的 UTF-8 文本」算的字节下标,托管副本就必须落盘这份
// UTF-8 文本:原始字节若是 GBK/Big5/UTF-16,按 UTF-8 区间去读会整章错位。
// 外部原地引用不能改写源文件(见 importLocalBook 的 externalPath 分支),
// 由读取侧按 charset 整文件解码后再切片。
out.payload = []byte(decoded)
for i, c := range splitTXTChapters(decoded) {
out.chaps = append(out.chaps, model.ReaderChapter{
Index: i, Title: c.Title, Tag: fmt.Sprintf("%d:%d", c.Start, c.End),
@@ -275,11 +280,21 @@ func (s *ReaderService) ImportLocalBook(
ext: parsed.ext,
charset: parsed.charset,
chaps: parsed.chaps,
payload: data,
payload: managedPayload(parsed, data),
coverEntry: parsed.coverEntry,
})
}
// managedPayload 托管副本实际落盘的字节。
//
// TXT 走 out.payload(已解码的 UTF-8,与章节下标的基准一致);EPUB 原样落盘。
func managedPayload(parsed *localBookPayload, raw []byte) []byte {
if parsed != nil && len(parsed.payload) > 0 {
return parsed.payload
}
return raw
}
// ImportLocalBookFromPath 从服务器上已有的文件导入书籍(TXT / EPUB)。
//
// 原地引用,不复制到 data/reader/local;同一个文件重复导入按覆盖更新处理,
@@ -590,14 +605,20 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader
if !ok {
return "", fmt.Errorf("章节定位信息损坏,请重新导入该书")
}
if end <= start {
return "", nil
}
// 外部原地引用的非 UTF-8 文件:章节区间是按导入时解码出的 UTF-8 文本算的,
// 必须整文件解码后再切片,直接读原始字节会错位(GBK 一字 2 字节、UTF-16 更多)。
// 托管副本在导入时已统一落盘 UTF-8,这里不会走这条分支。
if book.LocalExternal && needsDecodeBeforeSlice(book.Charset) {
return readLocalChapterDecoded(path, start, end)
}
f, err := os.Open(path) // #nosec G304 -- path 由服务端按书籍 ID 生成
if err != nil {
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
}
defer f.Close()
if end <= start {
return "", nil
}
buf := make([]byte, end-start)
if _, err := f.ReadAt(buf, int64(start)); err != nil && err != io.EOF {
return "", fmt.Errorf("读取章节失败: %w", err)
@@ -605,6 +626,36 @@ func (s *ReaderService) readLocalChapter(book *model.ReaderBook, ch model.Reader
return strings.TrimSpace(string(buf)), nil
}
// needsDecodeBeforeSlice 该字符集的文件是否需要在切片前整文件解码。
func needsDecodeBeforeSlice(charset string) bool {
switch strings.ToLower(strings.TrimSpace(charset)) {
case "", "utf-8", "utf8":
return false
}
return true
}
// readLocalChapterDecoded 整文件解码为 UTF-8 后按字节区间取章节(外部引用模式)。
func readLocalChapterDecoded(path string, start, end int) (string, error) {
f, err := os.Open(path) // #nosec G304 -- path 来自书籍记录,导入时已校验允许根目录
if err != nil {
return "", fmt.Errorf("本地书籍文件已丢失: %w", err)
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, LocalBookMaxBytes+1))
if err != nil {
return "", fmt.Errorf("读取章节失败: %w", err)
}
decoded, _ := decodeTextFile(data)
if start > len(decoded) {
return "", nil
}
if end > len(decoded) {
end = len(decoded)
}
return strings.TrimSpace(decoded[start:end]), nil
}
// DeleteLocalBookFile 移出书架时删除本地文件(网络书籍无文件,直接返回)。
//
// 原地引用的书指向服务器上已有的文件/目录,移出书架只解除引用,不动源文件。
+8 -8
View File
@@ -73,7 +73,7 @@ type LoginResult struct {
// GetSourceLogin 返回书源登录界面描述与当前登录状态。
func (s *ReaderService) GetSourceLogin(ctx context.Context, userID, sourceID string) (*SourceLoginInfo, error) {
src, bs, err := s.loadSource(ctx, sourceID)
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil {
return nil, err
}
@@ -142,7 +142,7 @@ func (s *ReaderService) resolveLoginFields(sess *sourceSession, bs *BookSource,
// fields 为前端提交的表单值,会与已保存的登录信息合并后作为 result 传入。
// action 为空时执行 loginUrl 里的 login()(即 legado 的「确认登录」)。
func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, action string, fields map[string]string) (*LoginResult, error) {
src, bs, err := s.loadSource(ctx, sourceID)
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil {
return nil, err
}
@@ -206,11 +206,11 @@ func (s *ReaderService) RunLoginAction(ctx context.Context, userID, sourceID, ac
// SetSourceVariable 覆盖保存书源变量(前端变量编辑器)。
// 保存后书源 JS 的 getVariable() 即可读到。
func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variable string) error {
func (s *ReaderService) SetSourceVariable(ctx context.Context, userID, sourceID, variable string) error {
if strings.TrimSpace(variable) != "" && !json.Valid([]byte(variable)) {
return fmt.Errorf("变量必须是合法 JSON")
}
src, bs, err := s.loadSource(ctx, sourceID)
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil {
return err
}
@@ -222,8 +222,8 @@ func (s *ReaderService) SetSourceVariable(ctx context.Context, sourceID, variabl
}
// SetSourceLoginInfo 直接覆盖保存登录信息(前端表单保存,不触发登录动作)。
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string, fields map[string]string) error {
src, bs, err := s.loadSource(ctx, sourceID)
func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, userID, sourceID string, fields map[string]string) error {
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil {
return err
}
@@ -239,8 +239,8 @@ func (s *ReaderService) SetSourceLoginInfo(ctx context.Context, sourceID string,
}
// ClearSourceLogin 清除登录态:登录信息与全部 Cookie(对应 legado logout)。
func (s *ReaderService) ClearSourceLogin(ctx context.Context, sourceID string) error {
src, bs, err := s.loadSource(ctx, sourceID)
func (s *ReaderService) ClearSourceLogin(ctx context.Context, userID, sourceID string) error {
src, bs, err := s.loadSourceForUser(ctx, userID, sourceID)
if err != nil {
return err
}
+19 -19
View File
@@ -22,7 +22,7 @@ import (
// 覆盖「登录 → Cookie 落库 → 后续请求自动携带 Cookie → 登出清理」。
// readerTestUserID 登录接口按用户隔离浏览器待办,测试里统一用一个固定用户。
const readerTestUserID = "test-user"
const readerTestUserID = "u1"
// loginTestServer 模拟一个需要登录的书源站点:
// - POST /login_api 校验账号密码并下发会话 Cookie
@@ -129,10 +129,10 @@ func newLoginTestService(t *testing.T) (*ReaderService, *repository.Container) {
// prepareLoginSource 导入测试书源并返回其 ID。
func prepareLoginSource(t *testing.T, svc *ReaderService, sourceJSON string) string {
t.Helper()
if _, err := svc.ImportSources(t.Context(), sourceJSON); err != nil {
if _, err := svc.ImportSources(t.Context(), "u1", sourceJSON); err != nil {
t.Fatal(err)
}
srcs, err := svc.ListSources(t.Context())
srcs, err := svc.ListSources(t.Context(), "u1")
if err != nil || len(srcs) == 0 {
t.Fatalf("导入后应能读到书源: %v", err)
}
@@ -151,14 +151,14 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
if books, _, err := svc.Search(ctx, "u1", "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
}
// ── 登录 ──
res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
res, err := svc.RunLoginAction(ctx, "u1", sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "pw123456",
})
if err != nil {
@@ -176,7 +176,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
// ── 登录态应落库(换一个 service 实例仍可读到)──
svc2 := NewReaderService(svc.cfg, zap.NewNop(), repos)
info, err := svc2.GetSourceLogin(ctx, readerTestUserID, sourceID)
info, err := svc2.GetSourceLogin(ctx, "u1", sourceID)
if err != nil {
t.Fatal(err)
}
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
books, skipped, err := svc.Search(ctx, "u1", "会员", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -200,17 +200,17 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登出:Cookie 清除,搜索重新未登录 ──
if err := svc.ClearSourceLogin(ctx, sourceID); err != nil {
if err := svc.ClearSourceLogin(ctx, "u1", sourceID); err != nil {
t.Fatal(err)
}
info, err = svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
info, err = svc.GetSourceLogin(ctx, "u1", sourceID)
if err != nil {
t.Fatal(err)
}
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
if books, _, err := svc.Search(ctx, "u1", "会员", nil, 1); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -224,7 +224,7 @@ func TestSourceLoginWrongPassword(t *testing.T) {
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
res, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "", map[string]string{
res, err := svc.RunLoginAction(t.Context(), "u1", sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "wrong",
})
if err != nil {
@@ -248,7 +248,7 @@ func TestSourceLoginInfo_ExposesUIFields(t *testing.T) {
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
info, err := svc.GetSourceLogin(t.Context(), readerTestUserID, sourceID)
info, err := svc.GetSourceLogin(t.Context(), "u1", sourceID)
if err != nil {
t.Fatal(err)
}
@@ -280,7 +280,7 @@ func TestSourceStateEncryptedAtRest(t *testing.T) {
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
if _, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "", map[string]string{
if _, err := svc.RunLoginAction(ctx, "u1", sourceID, "", map[string]string{
"邮箱": "user@example.com", "密码": "pw123456",
}); err != nil {
t.Fatal(err)
@@ -313,10 +313,10 @@ func TestSourceVariableRoundTrip(t *testing.T) {
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
if err := svc.SetSourceVariable(ctx, sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
if err := svc.SetSourceVariable(ctx, "u1", sourceID, `{"线路":"https://v2.example.com"}`); err != nil {
t.Fatal(err)
}
info, err := svc.GetSourceLogin(ctx, readerTestUserID, sourceID)
info, err := svc.GetSourceLogin(ctx, "u1", sourceID)
if err != nil {
t.Fatal(err)
}
@@ -329,7 +329,7 @@ func TestSourceVariableRoundTrip(t *testing.T) {
}
// 非法 JSON 应被拒绝
if err := svc.SetSourceVariable(ctx, sourceID, "not-json"); err == nil {
if err := svc.SetSourceVariable(ctx, "u1", sourceID, "not-json"); err == nil {
t.Fatal("非法 JSON 变量应被拒绝")
}
}
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
if _, _, err := svc.Search(ctx, "u1", "任意", nil, 1); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
@@ -386,10 +386,10 @@ func TestEnabledCookieJarGating(t *testing.T) {
defer srv2.Close()
srcJSONOn = strings.ReplaceAll(srcJSONOn, srv.URL, srv2.URL)
// prepareLoginSource 返回列表首个书源,这里按 URL 精确定位刚导入的对照源
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
if _, err := svc.ImportSources(ctx, "u1", srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
if _, _, err := svc.Search(ctx, "u1", "任意", nil, 1); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,504 @@
package reader
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件实现阅读正文的持久缓存(对应 legado BookHelp 的章节正文缓存 + CacheBook)。
//
// 设计要点:
// - 缓存「书源侧产物」:getContentFrom 的输出(书源 replaceRegex 之后,
// 用户替换规则、签名代理改写之前)。因此缓存里不含任何用户维度的处理结果,
// 用户替换规则在读出后逐请求应用,规则改动即时生效。
// - 缓存键按书源行组织(书源按用户独立,见 contentBookKey 的说明),
// 不同用户即使持有同一 URL 的书源也各自一份缓存。
// - 索引落库(ReaderContentCache),内容落盘
// (cache_dir/reader-content/<sourceKey>/<bookKey>/<chapterKey>.json)。
// - 缓存键只包含书源行、章节身份、书源指纹与格式版本:不含书籍行 ID
// (目录刷新会重建行,见 RemapContentCacheOnTocChange)。
// readerContentFormatVersion 载荷格式版本。解析管线(正文归一 / 段评提取 / 图片标记)
// 语义变化时必须递增:旧版本条目会被当作未命中并重抓,避免读到旧结构的缓存。
const readerContentFormatVersion = 1
// readerContentMaxEntryBytes 单条缓存的字节上限(超过不缓存,避免超大章节占满盘)。
const readerContentMaxEntryBytes = 16 << 20
// audioCacheTTL 音频清单的短 TTL:CDN 直链常带签名/过期参数,缓存太久会拿到失效地址。
const audioCacheTTL = 30 * time.Minute
// cachedChapterContent 落盘的载荷。
type cachedChapterContent struct {
FormatVersion int `json:"v"`
SourceHash string `json:"src"`
ContentType string `json:"type"`
Content string `json:"content,omitempty"`
Tracks []string `json:"tracks,omitempty"`
Images []string `json:"images,omitempty"`
ImageStyle string `json:"image_style,omitempty"`
IsHLS bool `json:"hls,omitempty"`
Comments []ContentComment `json:"comments,omitempty"`
SavedAt int64 `json:"saved_at"`
}
// contentFlight 一次进行中的正文抓取,并发的调用方共享结果(读穿透单飞)。
type contentFlight struct {
done chan struct{}
data *ChapterContent
err error
}
// ─── 键与身份 ──────────────────────────────────────────────────────────────
// contentBookKey 书源身份哈希:书源行 ID + 书本地址。
//
// 用书源行 ID 而不是 origin(书源 URL):书源按用户独立,不同用户可以持有同一
// URL 但规则/header 不同的副本,按 URL 共享缓存会把别人书源的产物喂进来。
// 用「行 ID + 书本地址」而不是书源显示名:聚合源的 origin 是显示名,多本同源书会撞。
func contentBookKey(sourceID, bookURL string) string {
sum := sha256.Sum256([]byte(sourceID + "\x00" + strings.TrimSpace(bookURL)))
return hex.EncodeToString(sum[:16])
}
// contentOriginKey 书源行维度的磁盘目录名(同一书源的不同书共享这一层)。
func contentOriginKey(sourceID string) string {
sum := sha256.Sum256([]byte(sourceID))
return hex.EncodeToString(sum[:16])
}
// contentChapterIdentity 章节身份原文:绝对化 URL 优先,卷/空地址退化为标题。
func contentChapterIdentity(book *model.ReaderBook, ch model.ReaderChapter) string {
if ch.URL != "" && !ch.IsVolume {
base := book.BookURL
if strings.TrimSpace(book.TocURL) != "" {
base = book.TocURL
}
if abs := rule.GetAbsoluteURL(base, ch.URL); abs != "" {
return "url|" + abs
}
return "url|" + strings.TrimSpace(ch.URL)
}
return "title|" + strings.TrimSpace(ch.Title)
}
// contentChapterKey 章节身份哈希。
func contentChapterKey(book *model.ReaderBook, ch model.ReaderChapter) string {
sum := sha256.Sum256([]byte(contentChapterIdentity(book, ch)))
return hex.EncodeToString(sum[:16])
}
// contentSourceHash 书源内容指纹:RawJSON 哈希(书源更新后旧缓存自然失效)。
func contentSourceHash(src *model.ReaderBookSource) string {
if src == nil {
return ""
}
sum := sha256.Sum256([]byte(src.RawJSON))
return hex.EncodeToString(sum[:16])
}
// ─── 磁盘布局 ──────────────────────────────────────────────────────────────
// readerContentDir 正文缓存根目录。
func (s *ReaderService) readerContentDir() string {
if s == nil || s.cfg == nil {
return ""
}
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
if base == "" {
if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" {
base = filepath.Join(dataDir, "cache")
}
}
if base == "" {
return ""
}
return filepath.Join(base, "reader-content")
}
// contentFilePath 单条缓存的磁盘路径:<root>/<sourceKey>/<bookKey>/<chapterKey>.json。
// chapterKey 已是 hex 哈希,不含路径分隔符。
func (s *ReaderService) contentFilePath(sourceID, bookURL, chapterKey string) string {
root := s.readerContentDir()
if root == "" {
return ""
}
return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL), chapterKey+".json")
}
// contentBookDir 某本书的缓存目录(整本清理用)。
func (s *ReaderService) contentBookDir(sourceID, bookURL string) string {
root := s.readerContentDir()
if root == "" {
return ""
}
return filepath.Join(root, contentOriginKey(sourceID), contentBookKey(sourceID, bookURL))
}
// writeContentFile 原子写入缓存文件(临时文件 + rename)。
func writeContentFile(path string, payload []byte) error {
if path == "" {
return fmt.Errorf("缓存目录未配置")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".content-*")
if err != nil {
return err
}
name := tmp.Name()
if _, err := tmp.Write(payload); err != nil {
_ = tmp.Close()
_ = os.Remove(name)
return err
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
// removeContentFile 删除缓存文件(不存在视为成功)。
func removeContentFile(path string) {
if path == "" {
return
}
_ = os.Remove(path)
}
// ─── 读写 ──────────────────────────────────────────────────────────────────
// contentSourceID 缓存键用的书源行 ID。src 缺失时按 origin 兜底查一次:
// 直接调用(测试、诊断路径)可能只拿到书籍与 origin。
func (s *ReaderService) contentSourceID(ctx context.Context, src *model.ReaderBookSource, book *model.ReaderBook) string {
if src != nil && src.ID != "" {
return src.ID
}
if s == nil || s.repo == nil || book == nil {
return ""
}
found, err := s.repo.GetSourceAnyByURL(ctx, book.Origin)
if err != nil || found == nil {
return ""
}
return found.ID
}
// loadCachedContent 读取一章节的缓存:命中返回内容与 true。
//
// contentType 为空表示「按章节取任意类型」(正文链路不需要预知类型);给出具体类型时
// 用于带类型校验的探测(如音频 TTL 的测试与诊断)。
//
// 校验链:索引存在 → 格式版本一致 → 书源指纹一致 → 未过期 → 载荷可解析。
func (s *ReaderService) loadCachedContent(ctx context.Context, src *model.ReaderBookSource, bs *BookSource, book *model.ReaderBook, ch model.ReaderChapter, contentType string) (*ChapterContent, bool) {
if s == nil || s.repo == nil || book == nil {
return nil, false
}
sourceID := s.contentSourceID(ctx, src, book)
if sourceID == "" {
return nil, false
}
bookKey := contentBookKey(sourceID, book.BookURL)
chapterKey := contentChapterKey(book, ch)
row, err := s.repo.GetContentCacheByChapter(ctx, bookKey, chapterKey, contentType)
if err != nil || row == nil {
return nil, false
}
if row.FormatVersion != readerContentFormatVersion {
s.dropContentCacheRow(ctx, row)
return nil, false
}
if row.SourceHash != "" && row.SourceHash != contentSourceHash(src) {
// 书源更新过:旧正文可能已失效,删掉重抓。
s.dropContentCacheRow(ctx, row)
return nil, false
}
now := time.Now().Unix()
if row.ExpiresAt > 0 && row.ExpiresAt < now {
s.dropContentCacheRow(ctx, row)
return nil, false
}
path := s.contentFilePath(sourceID, book.BookURL, chapterKey)
raw, err := os.ReadFile(path) // #nosec G304 -- 路径由服务端生成
if err != nil {
s.dropContentCacheRow(ctx, row)
return nil, false
}
var payload cachedChapterContent
if err := json.Unmarshal(raw, &payload); err != nil || payload.FormatVersion != readerContentFormatVersion {
s.dropContentCacheRow(ctx, row)
return nil, false
}
// 命中:刷新 LRU 时间与计数(异步失败不影响读取)。
_ = s.repo.TouchContentCache(ctx, row.ID, row.Hits+1, now)
out := &ChapterContent{
Type: payload.ContentType,
Content: payload.Content,
Tracks: payload.Tracks,
Images: payload.Images,
ImageStyle: payload.ImageStyle,
IsHLS: payload.IsHLS,
Comments: payload.Comments,
declaredType: -1,
}
return out, true
}
// saveCachedContent 写入一章节的缓存(空内容不写,与报错语义保持一致)。
func (s *ReaderService) saveCachedContent(ctx context.Context, src *model.ReaderBookSource, book *model.ReaderBook, ch model.ReaderChapter, out *ChapterContent) {
if s == nil || s.repo == nil || book == nil || out == nil || chapterContentEmpty(out) {
return
}
payload := cachedChapterContent{
FormatVersion: readerContentFormatVersion,
SourceHash: contentSourceHash(src),
ContentType: out.Type,
Content: out.Content,
Tracks: out.Tracks,
Images: out.Images,
ImageStyle: out.ImageStyle,
IsHLS: out.IsHLS,
Comments: out.Comments,
SavedAt: time.Now().Unix(),
}
raw, err := json.Marshal(payload)
if err != nil || len(raw) > readerContentMaxEntryBytes {
return
}
sourceID := s.contentSourceID(ctx, src, book)
if sourceID == "" {
return
}
path := s.contentFilePath(sourceID, book.BookURL, contentChapterKey(book, ch))
if err := writeContentFile(path, raw); err != nil {
if s.log != nil {
s.log.Warn("reader: 写入正文缓存失败", zap.String("path", path), zap.Error(err))
}
return
}
now := time.Now().Unix()
row := &model.ReaderContentCache{
OriginHash: contentOriginKey(sourceID),
BookKey: contentBookKey(sourceID, book.BookURL),
ChapterKey: contentChapterKey(book, ch),
ChapterIdentity: contentChapterIdentity(book, ch),
ChapterIndex: ch.Index,
ContentType: out.Type,
SourceHash: payload.SourceHash,
FormatVersion: readerContentFormatVersion,
SizeBytes: int64(len(raw)),
AssetCount: len(out.Tracks) + len(out.Images),
ExpiresAt: s.contentExpiry(out.Type, now),
LastAccessAt: now,
}
if err := s.repo.UpsertContentCache(ctx, row); err != nil && s.log != nil {
s.log.Warn("reader: 写入正文缓存索引失败", zap.Error(err))
}
}
// contentExpiry 按内容类型给出过期时间:音频清单短 TTL(直链会过期),文本/图片用配置的 TTL。
func (s *ReaderService) contentExpiry(contentType string, now int64) int64 {
if contentType == "audio" {
return now + int64(audioCacheTTL.Seconds())
}
ttlHours := 0
if s != nil && s.cfg != nil {
ttlHours = s.cfg.Cache.ReaderContentTTLHours
}
if ttlHours <= 0 {
return 0
}
return now + int64(ttlHours)*3600
}
// dropContentCacheRow 删除索引并清理磁盘文件。
func (s *ReaderService) dropContentCacheRow(ctx context.Context, row *model.ReaderContentCache) {
if s == nil || s.repo == nil || row == nil {
return
}
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
path := s.contentPathFromRow(row)
removeContentFile(path)
if path != "" {
s.pruneContentDirIfEmpty(filepath.Dir(path))
}
}
// pruneContentDirIfEmpty 删掉空目录(缓存清理后不留空壳)。
func (s *ReaderService) pruneContentDirIfEmpty(dir string) {
if dir == "" || dir == s.readerContentDir() {
return
}
if entries, err := os.ReadDir(dir); err == nil && len(entries) == 0 {
_ = os.Remove(dir)
}
}
// ClearContentCacheForBook 清理某书源行下一本书的全部正文缓存(换源/移出书架时调用)。
//
// sourceID 是书源行 ID:书源按用户独立,缓存也随之按书源行隔离,所以删除
// 自己那份书源或换源不会影响其他用户的同名书源缓存。
func (s *ReaderService) ClearContentCacheForBook(ctx context.Context, sourceID, bookURL string) {
if s == nil || s.repo == nil || sourceID == "" || bookURL == "" {
return
}
bookKey := contentBookKey(sourceID, bookURL)
if _, err := s.repo.DeleteContentCacheByBook(ctx, bookKey); err != nil {
return
}
if dir := s.contentBookDir(sourceID, bookURL); dir != "" {
_ = os.RemoveAll(dir)
s.pruneContentDirIfEmpty(filepath.Dir(dir))
}
}
// PruneContentCache 清理阅读正文缓存(TTL 过期 + 容量 LRU),供调度器按小时调用。
func (s *ReaderService) PruneContentCache(ctx context.Context) {
s.pruneReaderContentCache(ctx)
}
// pruneReaderContentCache 容量/TTL 淘汰:过期条目直接删,超配额按 LRU 删到 90%。
func (s *ReaderService) pruneReaderContentCache(ctx context.Context) {
if s == nil || s.repo == nil {
return
}
now := time.Now().Unix()
if expired, err := s.repo.ListContentCacheExpired(ctx, now, 500); err == nil {
for i := range expired {
row := expired[i]
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
removeContentFile(s.contentPathFromRow(&row))
}
}
maxMB := 0
if s.cfg != nil {
maxMB = s.cfg.Cache.ReaderContentMaxSizeMB
}
if maxMB <= 0 {
return
}
_, totalBytes, err := s.repo.ContentCacheStats(ctx)
if err != nil {
return
}
limit := int64(maxMB) << 20
if totalBytes <= limit {
return
}
target := limit * 9 / 10
oldest, err := s.repo.ListContentCacheOldest(ctx, 500)
if err != nil {
return
}
for i := range oldest {
if totalBytes <= target {
break
}
row := oldest[i]
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
removeContentFile(s.contentPathFromRow(&row))
totalBytes -= row.SizeBytes
}
}
// contentPathFromRow 由索引行拼磁盘路径(索引存了 origin_hash / book_key / chapter_key)。
func (s *ReaderService) contentPathFromRow(row *model.ReaderContentCache) string {
if s == nil || row == nil {
return ""
}
root := s.readerContentDir()
if root == "" {
return ""
}
return filepath.Join(root, row.OriginHash, row.BookKey, row.ChapterKey+".json")
}
// ─── 目录刷新后的缓存迁移(对应 legado BookHelp.remapContentCache) ─────────
// RemapContentCacheOnTocChange 目录刷新后迁移缓存键:同一章按绝对化 URL 命中、
// 标题唯一命中兜底。不迁移的话每次「更新目录」都会让已缓存正文全部冷启动。
func (s *ReaderService) RemapContentCacheOnTocChange(ctx context.Context, book *model.ReaderBook, oldChapters, newChapters []model.ReaderChapter) {
if s == nil || s.repo == nil || book == nil || len(oldChapters) == 0 || len(newChapters) == 0 {
return
}
sourceID := s.contentSourceID(ctx, nil, book)
if sourceID == "" {
return
}
bookKey := contentBookKey(sourceID, book.BookURL)
rows, err := s.repo.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return
}
// 旧身份 → 新章节。
byURL := map[string]model.ReaderChapter{}
byTitle := map[string][]model.ReaderChapter{}
for _, ch := range newChapters {
if ch.URL != "" && !ch.IsVolume {
base := book.BookURL
if strings.TrimSpace(book.TocURL) != "" {
base = book.TocURL
}
abs := rule.GetAbsoluteURL(base, ch.URL)
if abs == "" {
abs = strings.TrimSpace(ch.URL)
}
byURL["url|"+abs] = ch
}
if t := strings.TrimSpace(ch.Title); t != "" {
byTitle["title|"+t] = append(byTitle["title|"+t], ch)
}
}
for i := range rows {
row := rows[i]
var target model.ReaderChapter
found := false
if ch, ok := byURL[row.ChapterIdentity]; ok {
target, found = ch, true
} else if matches := byTitle[row.ChapterIdentity]; len(matches) == 1 {
// 标题唯一命中才迁移:多个同标题章节时无法确定是哪一章,宁可重抓。
target, found = matches[0], true
}
if !found {
continue
}
newKey := contentChapterKey(book, target)
if newKey == row.ChapterKey {
continue // 身份未变,只更新序号
}
oldPath := s.contentPathFromRow(&row)
newPath := s.contentFilePath(sourceID, book.BookURL, newKey)
if oldPath != "" && newPath != "" {
if err := os.MkdirAll(filepath.Dir(newPath), 0o750); err == nil {
// 文件不在(只留下索引)时忽略:新条目下次读取会自动重抓。
_ = os.Rename(oldPath, newPath)
}
}
_ = s.repo.DeleteContentCacheRow(ctx, row.ID)
row.ID = ""
row.ChapterKey = newKey
row.ChapterIdentity = contentChapterIdentity(book, target)
row.ChapterIndex = target.Index
row.LastAccessAt = time.Now().Unix()
_ = s.repo.UpsertContentCache(ctx, &row)
}
}
@@ -0,0 +1,404 @@
package reader
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// 正文持久缓存(reader_content_cache.go)的端到端测试:
// 抓取 → 写缓存 → 再次读取命中且不再访问书源 → 批量接口统计正确。
// cacheTestServer 每章返回固定正文并统计各章请求次数。
type cacheTestServer struct {
*httptest.Server
mu sync.Mutex
hits map[string]int
body func(chapter int) string
failOn map[int]bool
}
func newCacheTestServer() *cacheTestServer {
s := &cacheTestServer{hits: map[string]int{}, failOn: map[int]bool{}}
s.body = func(chapter int) string { return fmt.Sprintf("第%d章的正文内容。", chapter) }
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
switch {
case strings.HasPrefix(r.URL.Path, "/book/1/toc"):
_, _ = w.Write([]byte(cacheTestTocHTML))
case strings.HasPrefix(r.URL.Path, "/book/1/c"):
chapter := 0
_, _ = fmt.Sscanf(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/book/1/c"), ".html"), "%d", &chapter)
s.mu.Lock()
s.hits[fmt.Sprintf("c%d", chapter)]++
fail := s.failOn[chapter]
s.mu.Unlock()
if fail {
http.Error(w, "boom", http.StatusInternalServerError)
return
}
_, _ = w.Write([]byte(fmt.Sprintf(`<html><body><div id="content">%s</div></body></html>`, s.body(chapter))))
case strings.HasPrefix(r.URL.Path, "/book/"):
_, _ = w.Write([]byte(cacheTestBookInfoHTML))
default:
http.NotFound(w, r)
}
}))
return s
}
func (s *cacheTestServer) count(chapter int) int {
s.mu.Lock()
defer s.mu.Unlock()
return s.hits[fmt.Sprintf("c%d", chapter)]
}
func cacheTestSourceJSON(server string) string {
return fmt.Sprintf(`{
"bookSourceUrl": %q,
"bookSourceName": "缓存测试源",
"bookSourceType": 0,
"searchUrl": "%s/search/{{key}}/1.html",
"ruleSearch": {"bookList": "class.item", "name": "tag.h3@text", "bookUrl": "tag.a@href"},
"ruleBookInfo": {"name": "class.info@tag.h1@text", "tocUrl": "class.info@class.toc@href"},
"ruleToc": {"chapterList": "class.chapters@tag.li", "chapterName": "tag.a@text", "chapterUrl": "tag.a@href"},
"ruleContent": {"content": "id.content@textNodes"}
}`, server, server)
}
const cacheTestTocHTML = `<html><body>
<ul class="chapters">
<li><a href="/book/1/c1.html">第 1 章</a></li>
<li><a href="/book/1/c2.html">第 2 章</a></li>
<li><a href="/book/1/c3.html">第 3 章</a></li>
</ul></body></html>`
const cacheTestBookInfoHTML = `<html><body>
<div class="info"><h1>缓存之书</h1><a class="toc" href="/book/1/toc">目录</a></div>
</body></html>`
// prepareCacheTestBook 建好「书源 + 书架书籍 + 目录缓存」并打开独立缓存目录。
func prepareCacheTestBook(t *testing.T) (*ReaderService, *cacheTestServer, *model.ReaderBook) {
t.Helper()
srv := newCacheTestServer()
t.Cleanup(srv.Close)
svc, _ := newLoginTestService(t)
svc.cfg.Cache.CacheDir = t.TempDir()
svc.cfg.Cache.ReaderContentTTLHours = 168
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
ctx := t.Context()
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
BookURL: srv.URL + "/book/1",
}, "缓存之书", "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
chapters := make([]ChapterInput, 0, 3)
for i := 1; i <= 3; i++ {
chapters = append(chapters, ChapterInput{
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
})
}
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
return svc, srv, book
}
// 第一次读取抓源并写缓存;第二次直接命中持久缓存,不再访问书源。
func TestContentCacheHitAndPersist(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
first, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("首次读取失败: %v", err)
}
if !strings.Contains(first.Content, "第1章的正文内容。") {
t.Fatalf("正文不符合预期: %q", first.Content)
}
if got := srv.count(1); got != 1 {
t.Fatalf("首次读取应访问书源 1 次,实际 %d", got)
}
second, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("二次读取失败: %v", err)
}
if second.Content != first.Content {
t.Fatalf("缓存命中的正文不一致: %q vs %q", second.Content, first.Content)
}
if got := srv.count(1); got != 1 {
t.Fatalf("缓存命中不应再访问书源,实际 %d 次", got)
}
// 缓存文件确实落盘(键按书源行 ID 组织)。
sourceID := svc.contentSourceID(ctx, nil, book)
if sourceID == "" {
t.Fatal("找不到书源行")
}
path := svc.contentFilePath(sourceID, book.BookURL, contentChapterKey(book, model.ReaderChapter{
Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html",
}))
if path == "" {
t.Fatal("缓存路径为空(cache_dir 未生效)")
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("缓存文件未落盘: %v", err)
}
}
// 目录刷新后同一章(地址不变)仍应命中缓存,不因「更新目录」冷启动。
func TestContentCacheSurvivesTocRefresh(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
t.Fatalf("读取第 2 章失败: %v", err)
}
// 模拟「更新目录」:同一批章节重新落库(行 ID 会变,身份不变)。
chapters := make([]ChapterInput, 0, 3)
for i := 1; i <= 3; i++ {
chapters = append(chapters, ChapterInput{
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
})
}
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
t.Fatalf("重写目录失败: %v", err)
}
// 刷新后的 remap(refreshBookToc 内的同一调用)。
oldChapters := []model.ReaderChapter{
{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"},
{Index: 1, Title: "第 2 章", URL: srv.URL + "/book/1/c2.html"},
{Index: 2, Title: "第 3 章", URL: srv.URL + "/book/1/c3.html"},
}
newChapters, _ := svc.repo.ListChapters(ctx, book.ID)
svc.RemapContentCacheOnTocChange(ctx, book, oldChapters, newChapters)
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
t.Fatalf("刷新后读取失败: %v", err)
}
if got := srv.count(2); got != 1 {
t.Fatalf("目录刷新后应命中缓存(书源请求仍为 1 次),实际 %d 次", got)
}
}
// 并发请求同一章只应打一次书源(读穿透单飞)。
func TestContentCacheSingleFlight(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
var wg sync.WaitGroup
errs := make([]error, 6)
for i := 0; i < 6; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
_, errs[i] = svc.GetContentForBook(ctx, "u1", book.ID, 0)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Fatalf("并发读取 %d 失败: %v", i, err)
}
}
if got := srv.count(1); got != 1 {
t.Fatalf("同一章并发应单飞为 1 次书源请求,实际 %d 次", got)
}
}
// 批量接口:单章失败不影响其余章节,统计 hit/miss/failed。
func TestContentBatchStatsAndFailureIsolation(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
// 预热第 1 章(进缓存),第 2 章按失败处理。
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
t.Fatalf("预热失败: %v", err)
}
srv.mu.Lock()
srv.failOn[2] = true
srv.mu.Unlock()
result, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{0, 1, 2}, false)
if err != nil {
t.Fatalf("批量读取失败: %v", err)
}
if len(result.Items) != 3 {
t.Fatalf("应返回 3 项,实际 %d", len(result.Items))
}
if result.Stats.Hit != 1 {
t.Fatalf("第 1 章应命中缓存,hit=%d", result.Stats.Hit)
}
if result.Stats.Failed != 1 {
t.Fatalf("第 2 章应失败,failed=%d", result.Stats.Failed)
}
if result.Stats.Miss != 1 {
t.Fatalf("第 3 章应为新抓,miss=%d", result.Stats.Miss)
}
if !strings.Contains(result.Items[2].Content, "第3章的正文内容。") {
t.Fatalf("第 3 章内容异常: %+v", result.Items[2])
}
if result.Items[1].Error == "" {
t.Fatalf("第 2 章应带错误: %+v", result.Items[1])
}
// 失败章不写缓存:去掉失败开关后重试应真正抓源。
srv.mu.Lock()
srv.failOn[2] = false
srv.mu.Unlock()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 1); err != nil {
t.Fatalf("恢复后读取第 2 章失败: %v", err)
}
if got := srv.count(2); got != 2 {
t.Fatalf("失败章不应进缓存,恢复后应重抓(2 次),实际 %d", got)
}
// 越界/卷章节被忽略。
if got, err := svc.GetContentBatch(ctx, "u1", book.ID, []int{-1, 99}, false); err != nil || len(got.Items) != 0 {
t.Fatalf("越界索引应被忽略: items=%d err=%v", len(got.Items), err)
}
}
// 书源更新(RawJSON 变化)后旧缓存失效,重新抓取。
func TestContentCacheInvalidatedOnSourceUpdate(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
t.Fatalf("首次读取失败: %v", err)
}
// 重新导入同一书源(RawJSON 变化)→ 指纹变化。
updated := strings.Replace(cacheTestSourceJSON(srv.URL), "缓存测试源", "缓存测试源v2", 1)
if _, err := svc.ImportSources(ctx, "u1", updated); err != nil {
t.Fatalf("更新书源失败: %v", err)
}
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
t.Fatalf("更新后读取失败: %v", err)
}
if got := srv.count(1); got != 2 {
t.Fatalf("书源更新后应重抓(2 次),实际 %d 次", got)
}
}
// 移出书架且无其他引用时清理缓存文件与索引。
func TestContentCacheClearedOnRemoveBook(t *testing.T) {
svc, _, book := prepareCacheTestBook(t)
ctx := t.Context()
if _, err := svc.GetContentForBook(ctx, "u1", book.ID, 0); err != nil {
t.Fatalf("读取失败: %v", err)
}
sourceID := svc.contentSourceID(ctx, nil, book)
dir := svc.contentBookDir(sourceID, book.BookURL)
if _, err := os.Stat(dir); err != nil {
t.Fatalf("缓存目录应存在: %v", err)
}
if err := svc.RemoveBook(ctx, "u1", book.ID); err != nil {
t.Fatalf("移出书架失败: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatalf("移出书架后缓存目录应被清理,err=%v", err)
}
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)
}
}
// 音频清单使用短 TTL:过期后不命中并自动清理。
func TestContentCacheAudioShortTTL(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
ch := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}
src := mustSource(t, svc, srv.URL)
bookKey := contentBookKey(src.ID, book.BookURL)
svc.saveCachedContent(ctx, src, book, ch, &ChapterContent{
Type: "audio", Tracks: []string{srv.URL + "/a.mp3"},
})
row, err := svc.repo.GetContentCache(ctx, bookKey, contentChapterKey(book, ch), "audio")
if err != nil {
t.Fatalf("音频缓存未写入: %v", err)
}
if row.ExpiresAt == 0 {
t.Fatal("音频缓存应带 TTL")
}
// 过期后读取视为未命中,并清掉条目。
row.ExpiresAt = 1
if err := svc.repo.UpsertContentCache(ctx, row); err != nil {
t.Fatal(err)
}
if _, ok := svc.loadCachedContent(ctx, src, nil, book, ch, "audio"); ok {
t.Fatal("过期缓存不应命中")
}
if _, err := svc.repo.GetContentCache(ctx, bookKey, contentChapterKey(book, ch), "audio"); err == nil {
t.Fatal("过期条目应被删除")
}
}
// mustSource 取(并解析)指定书源记录,供直接调用缓存方法的测试使用。
func mustSource(t *testing.T, svc *ReaderService, sourceURL string) *model.ReaderBookSource {
t.Helper()
src, err := svc.repo.GetSourceByURL(context.Background(), "u1", sourceURL)
if err != nil {
t.Fatalf("取书源失败: %v", err)
}
return src
}
// 缓存键:不同书源/书本地址/章节地址必须落在不同键上。
func TestContentCacheKeys(t *testing.T) {
base := &model.ReaderBook{Origin: "https://a.com", BookURL: "https://a.com/book/1", TocURL: "https://a.com/toc/1"}
ch1 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "/book/1/c1.html"}
ch2 := model.ReaderChapter{Index: 0, Title: "第 1 章", URL: "https://a.com/book/1/c1.html"}
// 相对与绝对地址指向同一章:绝对化后身份一致。
if contentChapterKey(base, ch1) != contentChapterKey(base, ch2) {
t.Fatal("同一章的相对/绝对地址应得到同一身份")
}
if contentBookKey("https://a.com", "https://a.com/book/1") == contentBookKey("https://b.com", "https://a.com/book/1") {
t.Fatal("不同书源的同一地址不应共享键")
}
if contentChapterIdentity(base, model.ReaderChapter{Index: 0, Title: "卷名", IsVolume: true}) != "title|卷名" {
t.Fatal("卷章节应退化为标题身份")
}
}
// 缓存目录不可用时(未配置)不应影响正文读取。
func TestContentCacheWithoutCacheDir(t *testing.T) {
srv := newCacheTestServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
svc.cfg.Cache.CacheDir = ""
svc.cfg.App.DataDir = filepath.Join(t.TempDir(), "missing", "data")
srcID := importTestSource(t, svc, cacheTestSourceJSON(srv.URL), srv.URL)
ctx := t.Context()
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
SourceID: srcID, Origin: srv.URL, OriginName: "缓存测试源",
BookURL: srv.URL + "/book/1",
}, "缓存之书", "作者", "")
if err != nil {
t.Fatal(err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: srv.URL + "/book/1/c1.html"}}); err != nil {
t.Fatal(err)
}
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
if err != nil {
t.Fatalf("无缓存目录时读取失败: %v", err)
}
if !strings.Contains(out.Content, "第1章") {
t.Fatalf("正文异常: %q", out.Content)
}
}
+39 -4
View File
@@ -26,14 +26,20 @@ type AnalyzeRule struct {
chapterVars map[string]string
bookVars map[string]string
vars map[string]string
chapterTitle string
chapterIndex int
bookName string
// sourceDefaults 书源 JSON 的 variables 字段(作者设定的默认值)。
// 它只作为 @get 的兜底:显式保存过的书源变量与会话内 @put 的值优先级更高。
sourceDefaults map[string]string
chapterTitle string
chapterIndex int
bookName string
// bookMeta 书籍元数据(对应 legado 规则 JS 里的 Book 实体字段)。
bookMeta map[string]any
// bookCustom 书籍自定义变量(对应 legado Book.variableMap),
// 由规则 JS 的 book.getVariable / book.putVariable 读写。
bookCustom map[string]string
// bookVarPutter 由服务层注册:book.putVariable 写入后触发,
// 用于把变量变更持久化回书架记录(对应 legado 的 Book.upVariable)。
bookVarPutter func()
// bookTypeOverride 书源在规则 JS 里给 book.type 赋的值
// (听书/漫画/短剧源靠它声明书籍类型),由服务层读回。
bookTypeOverride *int
@@ -120,6 +126,17 @@ func (a *AnalyzeRule) SetBookCustomVars(vars map[string]string) {
}
}
// BookCustomVars 返回书籍自定义变量的当前值(可能被 book.putVariable 改过),
// 服务层据此把变更写回书架记录。没有书籍上下文时为 nil。
func (a *AnalyzeRule) BookCustomVars() map[string]string {
return a.bookCustom
}
// RegisterBookVariablePutter 注册书籍变量变更回调(book.putVariable 写入后触发)。
func (a *AnalyzeRule) RegisterBookVariablePutter(putter func()) {
a.bookVarPutter = putter
}
// SetBookType 记录书源声明的书籍类型(legado Book.type)。
func (a *AnalyzeRule) SetBookType(t int) {
v := t
@@ -154,6 +171,14 @@ func (a *AnalyzeRule) SetBookContext(name string, vars map[string]string) {
}
}
// SetSourceDefaults 注入书源 variables 字段的默认值(@get 的兜底)。
// 显式保存过的源变量与会话内 @put 的值优先级都高于它。
func (a *AnalyzeRule) SetSourceDefaults(vars map[string]string) {
if len(vars) > 0 {
a.sourceDefaults = vars
}
}
// SetSourceVariables 注入书源级变量读写(source.variableMap)。
func (a *AnalyzeRule) SetSourceVariables(getter func(key string) string, putter func(key, value string)) {
a.sourceGetter = getter
@@ -172,13 +197,19 @@ func jsonpathGet(path string, root any) (v any, err error) {
// ─── 变量存取(对应 put/get) ───────────────────────────────────────────────
// Put 对应 put(key, value):chapter → book → 局部 → source。
// Put 对应 put(key, value):chapter → book → 局部 → 书源持久变量。
//
// 「书源持久变量」是 @put 在 legado 里的真实落点(BaseSource.putVariable):
// 写进去的值跨请求可见。书源默认变量(variables 字段)与书籍自定义变量都
// 不是这个通道,所以最后才回退到 sourcePutter,而不是写一次性的 bookVars。
func (a *AnalyzeRule) Put(key, value string) string {
switch {
case a.chapterVars != nil:
a.chapterVars[key] = value
case a.bookVars != nil:
a.bookVars[key] = value
case a.sourcePutter != nil:
a.sourcePutter(key, value)
default:
if a.vars == nil {
a.vars = map[string]string{}
@@ -189,6 +220,7 @@ func (a *AnalyzeRule) Put(key, value string) string {
}
// Get 对应 get(key):特殊键 bookName/title 优先取上下文。
// 查找顺序 chapter → book → book 变量之上的显式源变量 → 书源默认变量。
func (a *AnalyzeRule) Get(key string) string {
switch key {
case "bookName":
@@ -212,6 +244,9 @@ func (a *AnalyzeRule) Get(key string) string {
return v
}
}
if v, ok := a.sourceDefaults[key]; ok {
return v
}
return ""
}
+9
View File
@@ -247,6 +247,12 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
return vm.ToValue("")
}
// 字节参数可能是 ArrayBuffer / typed array:这类值的 Export() 返回属性 map,
// 只有 ExportTo 才能取回字节,旧写法会退化成 String(value) 得到 "[object ArrayBuffer]"。
var buf []byte
if err := vm.ExportTo(call.Arguments[0], &buf); err == nil {
return vm.ToValue(string(buf))
}
if buf, ok := call.Arguments[0].Export().([]byte); ok {
return vm.ToValue(string(buf))
}
@@ -641,6 +647,9 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
// 任意脚本执行面,暂不支持,保持明确报错。
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
// 字体混淆还原(queryTTF / queryBase64TTF / replaceFont)。
r.installFontBridge(vm, set)
return o
}
+101 -1
View File
@@ -75,6 +75,9 @@ type JSConfig struct {
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
// 为空时这些函数抛出明确错误。
CacheDir string
// FetchBytes 拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
// 由服务层注入,复用书源 header / Cookie / 限速 / 重试。
FetchBytes func(absURL string) ([]byte, error)
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
Ctx context.Context
}
@@ -196,6 +199,18 @@ func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
return r.cfg.Fetch(req)
}
// cacheNamespace cache 对象的命名空间:优先取书源 URL,退回 BaseURL。
func (c JSConfig) cacheNamespace() string {
if c.SourceProps != nil {
if v, ok := c.SourceProps["bookSourceUrl"]; ok {
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
return s
}
}
}
return c.BaseURL
}
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
func NewJSRunner(cfg JSConfig) *JSRunner {
vm := goja.New()
@@ -208,7 +223,8 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
}
r := &JSRunner{vm: vm, cfg: cfg, vars: map[string]string{}, state: state}
vm.Set("cookie", newCookieObject(vm, state))
vm.Set("cache", newCacheObject(vm))
// cache 按书源命名空间隔离:namespace 取书源 URL,持久层落 CacheDir/reader-js-cache。
vm.Set("cache", newCacheObject(vm, cfg.cacheNamespace(), cfg.CacheDir))
// source 必须在 jsLib 之前注入:jsLib 的 getVariable/BaseUrl 依赖它。
srcObj := newSourceObject(vm, state, cfg.SourceProps)
vm.Set("source", srcObj)
@@ -558,6 +574,55 @@ func compileRuleJS(js string) (*goja.Program, error) {
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
}
// RunImageDecode 执行图片字节二次解密 JS(coverDecodeJs / ruleContent.imageDecode)。
//
// 对应 legado ImageUtils.getDecodeResult:绑定 result=图片字节、src=图片地址,
// 规则返回解密后的字节(ArrayBuffer / typed array)。执行失败返回 error,
// 调用方决定是回 502 还是原样透传。
func (r *JSRunner) RunImageDecode(js string, data []byte, src string) ([]byte, error) {
vm := r.vm
r.installJava(nil)
vm.Set("book", nil)
vm.Set("chapter", nil)
vm.Set("title", nil)
vm.Set("baseUrl", r.cfg.BaseURL)
vm.Set("result", vm.ToValue(vm.NewArrayBuffer(data)))
vm.Set("src", src)
vm.Set("key", nil)
vm.Set("page", nil)
vm.Set("nextChapterUrl", nil)
prog, err := compileRuleJS(stripRuleJSWrapper(js))
if err != nil {
return nil, fmt.Errorf("图片解密 JS 编译失败: %w", err)
}
g := newInterruptGuard(vm, r.cfg.Timeout, "图片解密超时")
r.setGuard(g)
defer func() {
g.Stop()
r.setGuard(nil)
}()
v, err := vm.RunProgram(prog)
if err != nil {
return nil, fmt.Errorf("图片解密失败: %v", err)
}
out, ok := exportBytes(vm, v)
if !ok {
return nil, fmt.Errorf("图片解密规则没有返回字节")
}
return out, nil
}
// FetchBytes 用书源的网络栈拉原始字节(queryTTF 的 URL 形态、图片解密前的取图)。
func (r *JSRunner) FetchBytes(absURL string) ([]byte, error) {
if r.cfg.FetchBytes == nil {
return nil, ErrJsUnsupported
}
resume := r.pauseTimeout()
defer resume()
return r.cfg.FetchBytes(absURL)
}
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
func exportValue(v goja.Value) any {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
@@ -571,6 +636,41 @@ func exportValue(v goja.Value) any {
}
}
// stripRuleJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
// 与 reader 包的 stripJSWrapper 同语义;规则包不能反向依赖 reader 包,故此处保留一份。
func stripRuleJSWrapper(s string) string {
trimmed := strings.TrimSpace(s)
lower := strings.ToLower(trimmed)
switch {
case strings.HasPrefix(lower, "@js:"):
return strings.TrimSpace(trimmed[len("@js:"):])
case strings.HasPrefix(lower, "<js>"):
body := trimmed[len("<js>"):]
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
return body
default:
return trimmed
}
}
// exportBytes 把 JS 返回值按字节取出:ArrayBuffer / typed array / 字符串。
// 图片与字体解密规则返回的都是字节,ArrayBuffer 的 Export() 只给出属性 map,
// 必须走 ExportTo 才能拿到真正的字节。
func exportBytes(vm *goja.Runtime, v goja.Value) ([]byte, bool) {
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil, false
}
var buf []byte
if err := vm.ExportTo(v, &buf); err == nil {
return buf, true
}
if s, ok := v.Export().(string); ok {
return []byte(s), true
}
return nil, false
}
// toJSValue 把引擎内部结果转为可注入 JS 的值。
// Element 列表等 DOM 结果以序列化字符串传入(对应 Rhino 的 Java 对象字符串化)。
func toJSValue(vm *goja.Runtime, v any) any {
+219 -47
View File
@@ -5,6 +5,9 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
@@ -171,6 +174,9 @@ func newBookObject(vm *goja.Runtime, a *AnalyzeRule) *goja.Object {
a.bookCustom = map[string]string{}
}
a.bookCustom[stringArg(call, 0)] = stringArgOr(call, 1, "")
if a.bookVarPutter != nil {
a.bookVarPutter()
}
return goja.Null()
})
return o
@@ -381,16 +387,183 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
var jsCache = struct {
mu sync.Mutex
m map[string]string
mem map[string]string
}{m: map[string]string{}, mem: map[string]string{}}
//
// 持久层按书源(bookSourceUrl)命名空间隔离,并落到 CacheDir/reader-js-cache:
// 之前是包级全局 map,任何书源的 put/get 全局可见,多个源用同一个 key 会互相串值,
// 一个源写满 4096 条还会把别的源的缓存一起清掉;重启后也全部丢失。
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
// jsCacheMaxEntries 单个书源命名空间的条目上限:超了按写入时间淘汰最旧的一半,
// 只影响本命名空间,不再波及其它书源。
const jsCacheMaxEntries = 4096
func newCacheObject(vm *goja.Runtime) *goja.Object {
// jsCacheNamespace 一个书源的持久/内存缓存命名空间。
type jsCacheNamespace struct {
mu sync.Mutex
data map[string]string
seq map[string]int64
next int64
path string
}
// jsCacheRegistry 按命名空间持有 cache,命名空间取书源 URL。
var jsCacheRegistry = struct {
mu sync.Mutex
m map[string]*jsCacheNamespace
}{m: map[string]*jsCacheNamespace{}}
// jsCacheFor 取(或创建)命名空间;cacheDir 非空时尝试从磁盘恢复。
func jsCacheFor(namespace, cacheDir string) *jsCacheNamespace {
if namespace == "" {
namespace = "__global__"
}
jsCacheRegistry.mu.Lock()
ns, ok := jsCacheRegistry.m[namespace]
if !ok {
ns = &jsCacheNamespace{data: map[string]string{}, seq: map[string]int64{}}
if cacheDir != "" {
ns.path = filepath.Join(cacheDir, "reader-js-cache", md5Hex(namespace, true)+".json")
}
ns.load()
jsCacheRegistry.m[namespace] = ns
}
jsCacheRegistry.mu.Unlock()
return ns
}
// load 从磁盘恢复命名空间(仅供 jsCacheFor 在注册表锁内首次调用)。
func (n *jsCacheNamespace) load() {
if n.path == "" {
return
}
raw, err := os.ReadFile(n.path) // #nosec G304 -- 路径由服务端生成
if err != nil || len(raw) == 0 {
return
}
var stored struct {
Data map[string]string `json:"data"`
Seq map[string]int64 `json:"seq"`
}
if json.Unmarshal(raw, &stored) != nil {
return
}
if stored.Data != nil {
n.data = stored.Data
}
if stored.Seq != nil {
n.seq = stored.Seq
for _, v := range stored.Seq {
if v > n.next {
n.next = v
}
}
}
}
// persist 把命名空间写回磁盘(临时文件 + 原子 rename)。
func (n *jsCacheNamespace) persist() {
if n.path == "" {
return
}
payload, err := json.Marshal(struct {
Data map[string]string `json:"data"`
Seq map[string]int64 `json:"seq"`
}{n.data, n.seq})
if err != nil {
return
}
if err := os.MkdirAll(filepath.Dir(n.path), 0o750); err != nil {
return
}
tmp, err := os.CreateTemp(filepath.Dir(n.path), ".js-cache-*")
if err != nil {
return
}
name := tmp.Name()
if _, err := tmp.Write(payload); err != nil {
_ = tmp.Close()
_ = os.Remove(name)
return
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return
}
_ = os.Rename(name, n.path)
}
// put 写一个持久键值(返回最终值)。
func (n *jsCacheNamespace) put(key, value string) string {
n.mu.Lock()
defer n.mu.Unlock()
if _, exists := n.data[key]; !exists && len(n.data) >= jsCacheMaxEntries {
n.evictOldestLocked()
}
n.next++
n.data[key] = value
n.seq[key] = n.next
n.persist()
return value
}
// evictOldestLocked 淘汰最旧的一半条目(调用方需持锁)。
func (n *jsCacheNamespace) evictOldestLocked() {
type entry struct {
key string
seq int64
}
entries := make([]entry, 0, len(n.data))
for k := range n.data {
entries = append(entries, entry{k, n.seq[k]})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].seq < entries[j].seq })
drop := len(entries)/2 + 1
for i := 0; i < drop && i < len(entries); i++ {
delete(n.data, entries[i].key)
delete(n.seq, entries[i].key)
}
}
// get 读一个持久键。
func (n *jsCacheNamespace) get(key string) (string, bool) {
n.mu.Lock()
defer n.mu.Unlock()
v, ok := n.data[key]
return v, ok
}
// del 删除一个持久键。
func (n *jsCacheNamespace) del(key string) {
n.mu.Lock()
defer n.mu.Unlock()
delete(n.data, key)
delete(n.seq, key)
n.persist()
}
// memoryStore 进程内内存缓存(putMemory/getFromMemory),同样按命名空间隔离。
var jsMemoryRegistry = struct {
mu sync.Mutex
m map[string]map[string]string
}{m: map[string]map[string]string{}}
func jsMemoryFor(namespace string) map[string]string {
if namespace == "" {
namespace = "__global__"
}
jsMemoryRegistry.mu.Lock()
defer jsMemoryRegistry.mu.Unlock()
store, ok := jsMemoryRegistry.m[namespace]
if !ok {
store = map[string]string{}
jsMemoryRegistry.m[namespace] = store
}
return store
}
// newCacheObject 构造 JS 的 `cache` 对象。
// namespace 取书源 URL;cacheDir 非空时 put/get 持久化到磁盘。
func newCacheObject(vm *goja.Runtime, namespace, cacheDir string) *goja.Object {
ns := jsCacheFor(namespace, cacheDir)
o := vm.NewObject()
set := func(k string, v any) {
if err := o.Set(k, v); err != nil {
@@ -398,51 +571,50 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
}
}
// 两套存储共用同一份读写实现,只有落点不同。
putTo := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
val := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
val = call.Arguments[1].String()
}
jsCache.mu.Lock()
if len(*store) >= jsCacheMaxEntries {
*store = map[string]string{}
}
(*store)[key] = val
jsCache.mu.Unlock()
return vm.ToValue(val)
set("put", func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
val := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
val = call.Arguments[1].String()
}
}
getFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
v, ok := (*store)[key]
jsCache.mu.Unlock()
if !ok {
return goja.Null()
}
return vm.ToValue(ns.put(key, val))
})
set("get", func(call goja.FunctionCall) goja.Value {
if v, ok := ns.get(stringArg(call, 0)); ok {
return vm.ToValue(v)
}
}
deleteFrom := func(store *map[string]string) func(goja.FunctionCall) goja.Value {
return func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
jsCache.mu.Lock()
delete(*store, key)
jsCache.mu.Unlock()
return goja.Null()
})
set("delete", func(call goja.FunctionCall) goja.Value {
ns.del(stringArg(call, 0))
return goja.Null()
})
// 内存缓存(legado Cache.getFromMemory / putMemory):进程内、不落盘。
mem := jsMemoryFor(namespace)
set("putMemory", func(call goja.FunctionCall) goja.Value {
key := stringArg(call, 0)
val := ""
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
val = call.Arguments[1].String()
}
jsMemoryRegistry.mu.Lock()
if _, exists := mem[key]; !exists && len(mem) >= jsCacheMaxEntries {
mem = map[string]string{}
jsMemoryRegistry.m[namespace] = mem
}
mem[key] = val
jsMemoryRegistry.mu.Unlock()
return vm.ToValue(val)
})
set("getFromMemory", func(call goja.FunctionCall) goja.Value {
jsMemoryRegistry.mu.Lock()
v, ok := mem[stringArg(call, 0)]
jsMemoryRegistry.mu.Unlock()
if !ok {
return goja.Null()
}
}
set("put", putTo(&jsCache.m))
set("get", getFrom(&jsCache.m))
set("delete", deleteFrom(&jsCache.m))
// 内存缓存(legado Cache.getFromMemory / putMemory)
set("putMemory", putTo(&jsCache.mem))
set("getFromMemory", getFrom(&jsCache.mem))
return vm.ToValue(v)
})
return o
}
@@ -51,3 +51,44 @@ func TestCacheMemoryRoundTrip(t *testing.T) {
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
}
}
// cache 必须按书源隔离:旧实现是包级全局 map,任一源的 put/get 对所有源可见,
// 多个源用同一个 key 会互相串值,一个源写满还会清掉别的源的缓存。
func TestCacheIsolatedPerSource(t *testing.T) {
srcA := map[string]any{"bookSourceUrl": "https://a.example.com"}
srcB := map[string]any{"bookSourceUrl": "https://b.example.com"}
ar := NewAnalyzeRule()
runnerA := NewJSRunner(JSConfig{SourceProps: srcA})
if _, err := runnerA.Run(ar, `cache.put('shared-key', 'from-a')`, nil, ""); err != nil {
t.Fatalf("源 A 写入失败: %v", err)
}
runnerB := NewJSRunner(JSConfig{SourceProps: srcB})
v, err := runnerB.Run(ar, `String(cache.get('shared-key'))`, nil, "")
if err != nil {
t.Fatalf("源 B 读取失败: %v", err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("源 B 读到了源 A 的缓存: %q", got)
}
// 源 A 自己重开运行时仍应读到(落到 CacheDir 之外的进程内持久层)。
runnerA2 := NewJSRunner(JSConfig{SourceProps: srcA})
v, err = runnerA2.Run(ar, `String(cache.get('shared-key'))`, nil, "")
if err != nil {
t.Fatalf("源 A 二次读取失败: %v", err)
}
if got := anyToString(v); got != "from-a" {
t.Fatalf("源 A 应读到自己的缓存: %q", got)
}
// 内存缓存同样按源隔离。
if _, err := runnerA.Run(ar, `cache.putMemory('mem-key', 'a')`, nil, ""); err != nil {
t.Fatal(err)
}
v, err = runnerB.Run(ar, `String(cache.getFromMemory('mem-key'))`, nil, "")
if err != nil {
t.Fatal(err)
}
if got := anyToString(v); got != "null" {
t.Fatalf("内存缓存跨源串值: %q", got)
}
}
+199
View File
@@ -0,0 +1,199 @@
package rule
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"sync"
"github.com/dop251/goja"
)
// 本文件对应 legado JsExtensions 的 queryTTF / replaceFont:
// 解析被字体混淆的正文。书源规则通常写成
//
// java.replaceFont(result, java.queryTTF(errorFontUrl), java.queryTTF(correctFontUrl))
//
// 错误字体把码点映射到错字形、正确字体把字形映射回真实码点,按字形轮廓配对即可还原。
// queryTTFCacheMax 已解析字体缓存条数(对齐 legado 的 LruCache 小容量策略)。
const queryTTFCacheMax = 32
var queryTTFCache = struct {
mu sync.Mutex
m map[string]*queryTTFFont
// order 记录插入顺序,超容量时淘汰最早的一条。
order []string
}{m: map[string]*queryTTFFont{}}
// queryTTFFromBytes 解析字体并缓存(key = SHA-256)。
func queryTTFFromBytes(data []byte, useCache bool) (*queryTTFFont, error) {
sum := sha256.Sum256(data)
key := hex.EncodeToString(sum[:])
if useCache {
queryTTFCache.mu.Lock()
if f, ok := queryTTFCache.m[key]; ok {
queryTTFCache.mu.Unlock()
return f, nil
}
queryTTFCache.mu.Unlock()
}
f, err := parseQueryTTFFont(data)
if err != nil {
return nil, err
}
if !useCache {
return f, nil
}
queryTTFCache.mu.Lock()
if len(queryTTFCache.order) >= queryTTFCacheMax {
oldest := queryTTFCache.order[0]
queryTTFCache.order = queryTTFCache.order[1:]
delete(queryTTFCache.m, oldest)
}
queryTTFCache.m[key] = f
queryTTFCache.order = append(queryTTFCache.order, key)
queryTTFCache.mu.Unlock()
return f, nil
}
// installFontBridge 注册 java.queryTTF / queryBase64TTF / replaceFont。
// set 是 java 对象的属性写入闭包(其中已包含 bridgeErr 的异常抛出语义)。
func (r *JSRunner) installFontBridge(vm *goja.Runtime, set func(string, any)) {
// fontErr 把错误翻译成 JS 异常:书源常把 queryTTF 放在 try/catch 里降级。
fontErr := func(name string, msg string) goja.Value {
return vm.ToValue("java." + name + ": " + msg)
}
set("queryTTF", func(call goja.FunctionCall) goja.Value {
useCache := true
if len(call.Arguments) > 1 {
useCache = call.Arguments[1].ToBoolean()
}
data, err := r.fontDataFromArg(call)
if err != nil {
panic(fontErr("queryTTF", err.Error()))
}
font, err := queryTTFFromBytes(data, useCache)
if err != nil {
panic(fontErr("queryTTF", err.Error()))
}
return vm.ToValue(font)
})
// queryBase64TTF 是 legado 的旧别名,语义完全相同。
set("queryBase64TTF", func(call goja.FunctionCall) goja.Value {
data, err := r.fontDataFromArg(call)
if err != nil {
panic(fontErr("queryBase64TTF", err.Error()))
}
font, err := queryTTFFromBytes(data, true)
if err != nil {
panic(fontErr("queryBase64TTF", err.Error()))
}
return vm.ToValue(font)
})
set("replaceFont", func(call goja.FunctionCall) goja.Value {
text := stringArg(call, 0)
errorFont := queryTTFFontArg(call, 1)
correctFont := queryTTFFontArg(call, 2)
filter := false
if len(call.Arguments) > 3 {
filter = call.Arguments[3].ToBoolean()
}
if errorFont == nil || correctFont == nil {
return vm.ToValue(text)
}
return vm.ToValue(replaceFontText(text, errorFont, correctFont, filter))
})
}
// fontDataFromArg 从 JS 参数取字体字节:URL / base64 / ArrayBuffer / typed array。
func (r *JSRunner) fontDataFromArg(call goja.FunctionCall) ([]byte, error) {
if len(call.Arguments) == 0 || goja.IsUndefined(call.Arguments[0]) || goja.IsNull(call.Arguments[0]) {
return nil, errQueryTTF("缺少字体参数")
}
// 字节形态(ArrayBuffer / Uint8Array)优先。
if data, ok := exportBytes(r.vm, call.Arguments[0]); ok && len(data) > 0 {
return data, nil
}
raw := strings.TrimSpace(call.Arguments[0].String())
if raw == "" {
return nil, errQueryTTF("缺少字体参数")
}
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
data, err := r.FetchBytes(raw)
if err != nil {
return nil, errQueryTTF("下载字体失败: " + err.Error())
}
return data, nil
}
// base64(支持 data:font/...;base64, 前缀与 URL-safe 变体)。
if i := strings.Index(raw, "base64,"); i >= 0 {
raw = raw[i+len("base64,"):]
}
data, err := base64DecodeBytes(raw)
if err != nil {
return nil, errQueryTTF("字体数据不是 base64")
}
return data, nil
}
// errQueryTTF 构造 queryTTF 相关错误(拼上函数名前缀由调用方负责)。
func errQueryTTF(msg string) error { return fmt.Errorf("%s", msg) }
// queryTTFFontArg 把 JS 参数还原成 *queryTTFFont。
// 书源常把 queryTTF 的结果存进变量再传给 replaceFont,goja 会原样保留 Go 指针。
func queryTTFFontArg(call goja.FunctionCall, idx int) *queryTTFFont {
if len(call.Arguments) <= idx {
return nil
}
if f, ok := call.Arguments[idx].Export().(*queryTTFFont); ok {
return f
}
return nil
}
// replaceFontText 按字形轮廓把错误字体渲染的文本还原成正确字体对应的真实文字。
// 顺序对齐 legado JsExtensions.replaceFont:逐码点取错字形,再到正确字体查回码点。
func replaceFontText(text string, errorFont, correctFont *queryTTFFont, filter bool) string {
var sb strings.Builder
sb.Grow(len(text))
for _, cp := range text {
if isBlankUnicode(cp) {
sb.WriteRune(cp)
continue
}
glyph := errorFont.unicodeToGlyph[cp]
if _, ok := errorFont.unicodeToGlyphID[cp]; !ok {
// 错误字体里没有这个码点(对应 legado 的 glyfId == 0 → 视为无字形)。
glyph = ""
}
if glyph == "" {
if filter {
continue
}
sb.WriteRune(cp)
continue
}
if real, ok := correctFont.glyphToUnicode[glyph]; ok && real != 0 {
sb.WriteRune(real)
continue
}
if filter {
continue
}
sb.WriteRune(cp)
}
return sb.String()
}
// isBlankUnicode 判断码点是否是不可见的空白(对齐 legado 的 isBlankUnicode 列表)。
func isBlankUnicode(cp rune) bool {
switch cp {
case ' ', '\t', '\n', '\r', '\v', '\f', 0x00A0, 0x2000, 0x2001, 0x2002, 0x2003,
0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200A, 0x2028, 0x2029,
0x202F, 0x205F, 0x3000, 0xFEFF:
return true
}
return false
}
+22 -7
View File
@@ -10,6 +10,23 @@ import (
// 本文件对应 AnalyzeByRegex.kt。Java 正则语义用 regexp2 对齐
// (支持前向后向断言与反向引用),匹配循环对齐 Matcher.find()。
// regexMatchTimeout 书源正则的匹配预算。
//
// regexp2 默认永不超时(DefaultMatchTimeout 是 MaxInt64),而书源正则来自
// 不可信内容:灾难性回溯会永久占住一个 goroutine 和一颗 CPU 核。这里统一
// 设一个短预算,语义与 ApplyUserReplace 的缺省值保持一致。
const regexMatchTimeout = 3 * time.Second
// compileRegex 编译一条 Java 语义正则并设置匹配超时。
func compileRegex(pattern string) (*regexp2.Regexp, error) {
re, err := regexp2.Compile(pattern, regexp2.None)
if err != nil {
return nil, err
}
re.MatchTimeout = regexMatchTimeout
return re, nil
}
// splitNotBlankAndTrim 对应 String.splitNotBlank("&&"):切分并去空白项。
func splitNotBlankAndTrim(s, sep string) []string {
var out []string
@@ -27,7 +44,7 @@ func regexGetElement(res string, regs []string, index int) []string {
if index >= len(regs) {
return nil
}
re, err := regexp2.Compile(regs[index], regexp2.None)
re, err := compileRegex(regs[index])
if err != nil {
return nil
}
@@ -60,7 +77,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
if index >= len(regs) {
return nil
}
re, err := regexp2.Compile(regs[index], regexp2.None)
re, err := compileRegex(regs[index])
if err != nil {
return nil
}
@@ -95,7 +112,7 @@ func regexGetElements(res string, regs []string, index int) [][]string {
// regexReplaceAll 对应 Kotlin Regex.replace(result, replacement)
// (Java $N 分组替换语义,regexp2 的 Replace 原生支持)。
func regexReplaceAll(pattern, result, replacement string) string {
re, err := regexp2.Compile(pattern, regexp2.None)
re, err := compileRegex(pattern)
if err != nil {
return strings.ReplaceAll(result, pattern, replacement)
}
@@ -116,14 +133,12 @@ func ApplyUserReplace(content, pattern, replacement string, isRegex bool, timeou
if !isRegex {
return strings.ReplaceAll(content, pattern, replacement)
}
re, err := regexp2.Compile(pattern, regexp2.None)
re, err := compileRegex(pattern)
if err != nil {
return strings.ReplaceAll(content, pattern, replacement)
}
if timeoutMS > 0 {
re.MatchTimeout = time.Duration(timeoutMS) * time.Millisecond
} else {
re.MatchTimeout = 3 * time.Second
}
out, err := re.Replace(content, replacement, 0, -1)
if err != nil {
@@ -160,7 +175,7 @@ func ApplyReplaceRegexString(content, replaceRegex string) string {
// regexReplaceFirstOnFirstMatch 对应 replaceRegex 的 replaceFirst 分支:
// 找到第一个匹配(无匹配返回 ""),在匹配文本上做首次替换。
func regexReplaceFirstOnFirstMatch(pattern, result, replacement string) string {
re, err := regexp2.Compile(pattern, regexp2.None)
re, err := compileRegex(pattern)
if err != nil {
return replacement
}
+375
View File
@@ -0,0 +1,375 @@
package rule
import (
"encoding/binary"
"fmt"
"strings"
)
// 本文件移植 legado 的 QueryTTF:解析字体(sfnt)的 cmap / glyf / loca / maxp 表,
// 建立「Unicode 码点 → 字形」「字形 → Unicode 码点」两张表,供
// java.queryTTF / java.replaceFont 还原被字体混淆的正文。
//
// 阅读站点的常见套路是:正文用一套打乱过的字体渲染,页面上给出「错误字体」(把
// 每个码点映射到错字形)与「正确字体」(字形到真实码点的映射)。replaceFont 按
// 字形轮廓把错误字体里的字符逐个换成正确字体里同字形的码点,从而还原原文。
//
// 解析器全程做边界检查:字体字节来自书源(不可信),坏字体只应报错,不能 panic。
// queryTTFFont 一个已解析的字体。
type queryTTFFont struct {
// unicodeToGlyphID 码点 → 字形在 glyf 表里的下标。
unicodeToGlyphID map[rune]uint16
// unicodeToGlyph 码点 → 字形轮廓(用于跨字体比较字形)。
unicodeToGlyph map[rune]string
// glyphToUnicode 字形轮廓 → 码点(正确字体用来查回真实字符)。
glyphToUnicode map[string]rune
}
// sfnt 表标签。
var (
ttfTagCmap = [4]byte{'c', 'm', 'a', 'p'}
ttfTagGlyf = [4]byte{'g', 'l', 'y', 'f'}
ttfTagLoca = [4]byte{'l', 'o', 'c', 'a'}
ttfTagMaxp = [4]byte{'m', 'a', 'x', 'p'}
ttfTagHead = [4]byte{'h', 'e', 'a', 'd'}
)
// parseQueryTTFFont 解析字体字节。支持 sfnt(TTF/OTF)与 ttc 的第一套字体。
func parseQueryTTFFont(data []byte) (font *queryTTFFont, err error) {
defer func() {
if r := recover(); r != nil {
font, err = nil, fmt.Errorf("字体解析失败: %v", r)
}
}()
if len(data) < 12 {
return nil, fmt.Errorf("字体数据过短")
}
// ttc:取第一套字体的偏移。
if string(data[:4]) == "ttcf" {
if len(data) < 16 {
return nil, fmt.Errorf("ttc 头部不完整")
}
off := int(binary.BigEndian.Uint32(data[12:16]))
if off <= 0 || off >= len(data) {
return nil, fmt.Errorf("ttc 字体偏移非法")
}
data = data[off:]
}
numTables := int(binary.BigEndian.Uint16(data[4:6]))
if numTables <= 0 || 12+numTables*16 > len(data) {
return nil, fmt.Errorf("sfnt 表目录非法")
}
tables := map[[4]byte][]byte{}
for i := 0; i < numTables; i++ {
rec := data[12+i*16 : 12+i*16+16]
var tag [4]byte
copy(tag[:], rec[:4])
off := int(binary.BigEndian.Uint32(rec[8:12]))
length := int(binary.BigEndian.Uint32(rec[12:16]))
if off < 0 || length < 0 || off > len(data) {
continue
}
if off+length > len(data) {
length = len(data) - off
}
tables[tag] = data[off : off+length]
}
cmap := tables[ttfTagCmap]
if cmap == nil {
return nil, fmt.Errorf("字体缺少 cmap 表")
}
mapping, err := parseTTFCmap(cmap)
if err != nil {
return nil, err
}
f := &queryTTFFont{
unicodeToGlyphID: mapping,
unicodeToGlyph: map[rune]string{},
glyphToUnicode: map[string]rune{},
}
// 有 glyf + loca + head + maxp 才能算字形轮廓;缺了(如 CFF 字体)只保留码点映射。
head := tables[ttfTagHead]
maxp := tables[ttfTagMaxp]
loca := tables[ttfTagLoca]
glyf := tables[ttfTagGlyf]
if head == nil || maxp == nil || loca == nil || glyf == nil || len(head) < 54 {
return f, nil
}
indexToLocFormat := int16(binary.BigEndian.Uint16(head[50:52]))
numGlyphs := int(binary.BigEndian.Uint16(maxp[4:6]))
offsets, err := parseTTFLoca(loca, numGlyphs, indexToLocFormat)
if err != nil {
return f, nil
}
glyphCache := map[uint16]string{}
for cp, gid := range mapping {
outline := ttfGlyphOutline(glyf, offsets, gid, glyphCache, 0)
f.unicodeToGlyph[cp] = outline
if _, exists := f.glyphToUnicode[outline]; !exists {
f.glyphToUnicode[outline] = cp
}
}
return f, nil
}
// parseTTFCmap 解析 cmap 表,返回码点 → 字形下标。
// 支持 format 0 / 4 / 6(legado QueryTTF 同样只支持这三种)。
func parseTTFCmap(cmap []byte) (map[rune]uint16, error) {
if len(cmap) < 4 {
return nil, fmt.Errorf("cmap 表过短")
}
numTables := int(binary.BigEndian.Uint16(cmap[2:4]))
if 4+numTables*8 > len(cmap) {
return nil, fmt.Errorf("cmap 子表目录非法")
}
out := map[rune]uint16{}
for i := 0; i < numTables; i++ {
rec := cmap[4+i*8 : 4+i*8+8]
off := int(binary.BigEndian.Uint32(rec[4:8]))
if off < 0 || off+2 > len(cmap) {
continue
}
sub := cmap[off:]
switch binary.BigEndian.Uint16(sub[0:2]) {
case 0:
parseCmapFormat0(sub, out)
case 4:
parseCmapFormat4(sub, out)
case 6:
parseCmapFormat6(sub, out)
}
// 优先保留第一个子表解析到的映射;后续子表只补缺失项。
}
if len(out) == 0 {
return nil, fmt.Errorf("cmap 没有可用的 format 0/4/6 子表")
}
return out, nil
}
func parseCmapFormat0(sub []byte, out map[rune]uint16) {
if len(sub) < 262 {
return
}
length := int(binary.BigEndian.Uint16(sub[2:4]))
if length > len(sub) {
length = len(sub)
}
glyphs := sub[6:min(6+256, length)]
for i, gid := range glyphs {
if gid != 0 {
if _, exists := out[rune(i)]; !exists {
out[rune(i)] = uint16(gid)
}
}
}
}
func parseCmapFormat4(sub []byte, out map[rune]uint16) {
if len(sub) < 14 {
return
}
segCountX2 := int(binary.BigEndian.Uint16(sub[6:8]))
segCount := segCountX2 / 2
if segCount == 0 {
return
}
endBase := 14
startBase := endBase + segCountX2 + 2
deltaBase := startBase + segCountX2
rangeBase := deltaBase + segCountX2
if rangeBase+segCountX2 > len(sub) {
return
}
for i := 0; i < segCount; i++ {
end := int(binary.BigEndian.Uint16(sub[endBase+i*2:]))
start := int(binary.BigEndian.Uint16(sub[startBase+i*2:]))
delta := int16(binary.BigEndian.Uint16(sub[deltaBase+i*2:]))
rangeOffset := int(binary.BigEndian.Uint16(sub[rangeBase+i*2:]))
if start > end {
continue
}
for cp := start; cp <= end && cp <= 0xFFFF; cp++ {
if cp == 0xFFFF {
continue
}
var gid uint16
if rangeOffset == 0 {
gid = uint16(int(cp) + int(delta))
} else {
idx := rangeBase + i*2 + rangeOffset + (cp-start)*2
if idx+2 > len(sub) {
continue
}
g := binary.BigEndian.Uint16(sub[idx : idx+2])
if g == 0 {
continue
}
gid = uint16(int(g) + int(delta))
}
if gid != 0 {
if _, exists := out[rune(cp)]; !exists {
out[rune(cp)] = gid
}
}
}
}
}
func parseCmapFormat6(sub []byte, out map[rune]uint16) {
if len(sub) < 10 {
return
}
first := int(binary.BigEndian.Uint16(sub[6:8]))
count := int(binary.BigEndian.Uint16(sub[8:10]))
for i := 0; i < count; i++ {
idx := 10 + i*2
if idx+2 > len(sub) {
return
}
gid := binary.BigEndian.Uint16(sub[idx : idx+2])
if gid == 0 {
continue
}
cp := rune(first + i)
if _, exists := out[cp]; !exists {
out[cp] = gid
}
}
}
// parseTTFLoca 解析 loca 表,返回每个字形的字节区间起止。
func parseTTFLoca(loca []byte, numGlyphs int, indexToLocFormat int16) ([]int, error) {
if indexToLocFormat == 0 {
need := (numGlyphs + 1) * 2
if len(loca) < need {
return nil, fmt.Errorf("loca 表过短")
}
out := make([]int, numGlyphs+1)
for i := 0; i <= numGlyphs; i++ {
out[i] = int(binary.BigEndian.Uint16(loca[i*2:])) * 2
}
return out, nil
}
need := (numGlyphs + 1) * 4
if len(loca) < need {
return nil, fmt.Errorf("loca 表过短")
}
out := make([]int, numGlyphs+1)
for i := 0; i <= numGlyphs; i++ {
out[i] = int(binary.BigEndian.Uint32(loca[i*4:]))
}
return out, nil
}
// ttfGlyphOutline 把字形转成轮廓字符串(对应 legado QueryTTF.Glyf.toString)。
// 复合字形递归展开组件,深度上限防自引用。
func ttfGlyphOutline(glyf []byte, offsets []int, gid uint16, cache map[uint16]string, depth int) string {
if depth > 8 {
return fmt.Sprintf("glyph%d", gid)
}
if v, ok := cache[gid]; ok {
return v
}
if int(gid)+1 >= len(offsets) {
return fmt.Sprintf("glyph%d", gid)
}
start, end := offsets[gid], offsets[gid+1]
if start < 0 || end > len(glyf) || end <= start {
// 空字形(如空格):用下标本身当轮廓,保证不同码点不会互相误判。
out := fmt.Sprintf("glyph%d", gid)
cache[gid] = out
return out
}
numberOfContours := int16(binary.BigEndian.Uint16(glyf[start : start+2]))
if numberOfContours >= 0 {
out := fmt.Sprintf("simple:%d:%s", numberOfContours, ttfSimpleGlyphPoints(glyf[start:end]))
cache[gid] = out
return out
}
// 复合字形:逐组件展开。
var sb strings.Builder
sb.WriteString("composite")
pos := start + 10
for pos+4 <= end {
flags := binary.BigEndian.Uint16(glyf[pos : pos+2])
component := binary.BigEndian.Uint16(glyf[pos+2 : pos+4])
pos += 4
if flags&0x0001 != 0 { // ARG_1_AND_2_ARE_WORDS
pos += 4
} else {
pos += 2
}
switch {
case flags&0x0008 != 0: // WE_HAVE_A_SCALE
pos += 2
case flags&0x0040 != 0: // WE_HAVE_AN_X_AND_Y_SCALE
pos += 4
case flags&0x0080 != 0: // WE_HAVE_A_TWO_BY_TWO
pos += 8
}
sb.WriteString("+")
sb.WriteString(ttfGlyphOutline(glyf, offsets, component, cache, depth+1))
if flags&0x0020 == 0 { // MORE_COMPONENTS
break
}
}
out := sb.String()
cache[gid] = out
return out
}
// ttfSimpleGlyphPoints 取简单字形的轮廓点(标志与坐标的紧凑编码)。
func ttfSimpleGlyphPoints(data []byte) string {
if len(data) < 10 {
return ""
}
numberOfContours := int(binary.BigEndian.Uint16(data[0:2]))
if numberOfContours <= 0 {
return ""
}
endPtsPos := 10
if endPtsPos+numberOfContours*2+2 > len(data) {
return ""
}
numPoints := int(binary.BigEndian.Uint16(data[endPtsPos+(numberOfContours-1)*2:])) + 1
if numPoints <= 0 {
return ""
}
pos := endPtsPos + numberOfContours*2 + 2 // + instructionLength
if pos > len(data) {
return ""
}
instrLen := int(binary.BigEndian.Uint16(data[pos-2 : pos]))
pos += instrLen
flags := make([]byte, 0, numPoints)
for len(flags) < numPoints && pos < len(data) {
flag := data[pos]
pos++
flags = append(flags, flag)
if flag&0x08 != 0 { // REPEAT
if pos >= len(data) {
break
}
repeat := int(data[pos])
pos++
for i := 0; i < repeat && len(flags) < numPoints; i++ {
flags = append(flags, flag)
}
}
}
// 解析 x / y 坐标(与点数等长的增量序列,这里只用于区分字形)。
var sb strings.Builder
sb.WriteString(fmt.Sprintf("n=%d;", numPoints))
for _, flag := range flags {
sb.WriteByte('0' + flag&0x0F)
}
sb.WriteString(";")
// 跳过坐标数据不影响「同名轮廓一致性」的判断:同字形的字体坐标编码一致。
if pos < len(data) {
sb.WriteString(fmt.Sprintf("d=%d", len(data)-pos))
}
return sb.String()
}
+231
View File
@@ -0,0 +1,231 @@
package rule
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
// 字体混淆还原(ttf.go / queryttf.go)的测试:用合成的 TTF 覆盖 cmap format 0/4、
// loca 短格式、简单/复合字形,验证「错误字体 → 正确字体」的按字形还原。
// buildTestTTF 构造一个最小可解析的 TTF:
// cmap(format 4)把给定码点映射到指定字形下标,glyf 里每个字形一个方框。
func buildTestTTF(t *testing.T, entries map[rune]uint16) []byte {
t.Helper()
const numGlyphs = 8
// 每个字形一个简单方框:轮廓数 1,1 个点。
glyph := func() []byte {
g := make([]byte, 0, 20)
g = append(g, 0x00, 0x01) // numberOfContours = 1
g = append(g, 0, 0, 0, 0, 0, 0, 0, 0) // bbox
g = append(g, 0x00, 0x00) // endPtsOfContours[0] = 0
g = append(g, 0x00, 0x00) // instructionLength = 0
g = append(g, 0x01, 0x01) // flag: on-curve | x-short | y-short
g = append(g, 0x00, 0x00) // x=0, y=0
return g
}
glyfData := make([]byte, 0, 128)
offsets := make([]int, 0, numGlyphs+1)
for i := 0; i < numGlyphs; i++ {
offsets = append(offsets, len(glyfData))
if i == 0 {
continue // .notdef 空字形
}
g := glyph()
// 让不同字形的字节长度不同,轮廓字符串才能区分。
for j := 0; j < i-1; j++ {
g = append(g, 0x01, 0x00, 0x00) // 额外点
}
glyfData = append(glyfData, g...)
}
offsets = append(offsets, len(glyfData))
// loca(短格式,偏移/2)
loca := make([]byte, (numGlyphs+1)*2)
for i, off := range offsets {
binary.BigEndian.PutUint16(loca[i*2:], uint16(off/2))
}
// cmap:format 4 单段 + 结束段
var cmap bytes.Buffer
codes := make([]rune, 0, len(entries))
for cp := range entries {
codes = append(codes, cp)
}
// 按码点排序,构造连续单点段。
for i := 0; i < len(codes); i++ {
for j := i + 1; j < len(codes); j++ {
if codes[j] < codes[i] {
codes[i], codes[j] = codes[j], codes[i]
}
}
}
segCount := len(codes) + 1
endCodes := make([]uint16, 0, segCount)
startCodes := make([]uint16, 0, segCount)
idDeltas := make([]uint16, 0, segCount)
for _, cp := range codes {
endCodes = append(endCodes, uint16(cp))
startCodes = append(startCodes, uint16(cp))
idDeltas = append(idDeltas, uint16(int(entries[cp])-int(cp)))
}
endCodes = append(endCodes, 0xFFFF)
startCodes = append(startCodes, 0xFFFF)
idDeltas = append(idDeltas, 1)
rangeOffsets := make([]uint16, segCount) // 全 0:用 idDelta
sub := new(bytes.Buffer)
writeU16 := func(v uint16) { _ = binary.Write(sub, binary.BigEndian, v) }
length := 16 + segCount*8
writeU16(4) // format
writeU16(uint16(length)) // length
writeU16(0) // language
writeU16(uint16(segCount * 2)) // segCountX2
writeU16(0) // searchRange(解析器不校验)
writeU16(0) // entrySelector
writeU16(0) // rangeShift
for _, v := range endCodes {
writeU16(v)
}
writeU16(0) // reservedPad
for _, v := range startCodes {
writeU16(v)
}
for _, v := range idDeltas {
writeU16(v)
}
for _, v := range rangeOffsets {
writeU16(v)
}
subBytes := sub.Bytes()
// cmap 头 + 一个子表记录
cmap.Write([]byte{0, 0})
_ = binary.Write(&cmap, binary.BigEndian, uint16(1))
_ = binary.Write(&cmap, binary.BigEndian, uint16(3)) // platformID = Windows
_ = binary.Write(&cmap, binary.BigEndian, uint16(1)) // encodingID = Unicode BMP
_ = binary.Write(&cmap, binary.BigEndian, uint32(12))
cmap.Write(subBytes)
head := make([]byte, 54)
binary.BigEndian.PutUint16(head[50:], 0) // indexToLocFormat = 0(短 loca)
maxp := make([]byte, 6)
binary.BigEndian.PutUint16(maxp[4:], numGlyphs)
tables := []struct {
tag string
data []byte
}{
{"cmap", cmap.Bytes()},
{"glyf", glyfData},
{"loca", loca},
{"head", head},
{"maxp", maxp},
}
var out bytes.Buffer
out.Write([]byte{0x00, 0x01, 0x00, 0x00}) // sfntVersion
_ = binary.Write(&out, binary.BigEndian, uint16(len(tables)))
_ = binary.Write(&out, binary.BigEndian, uint16(0))
_ = binary.Write(&out, binary.BigEndian, uint16(0))
_ = binary.Write(&out, binary.BigEndian, uint16(0))
offset := 12 + len(tables)*16
offsetsTable := make([]int, len(tables))
for i, tb := range tables {
padded := tb.data
if len(padded)%4 != 0 {
padded = append(padded, make([]byte, 4-len(padded)%4)...)
}
offsetsTable[i] = offset
offset += len(padded)
}
for i, tb := range tables {
out.WriteString(tb.tag)
_ = binary.Write(&out, binary.BigEndian, uint32(0))
_ = binary.Write(&out, binary.BigEndian, uint32(offsetsTable[i]))
_ = binary.Write(&out, binary.BigEndian, uint32(len(tb.data)))
}
for _, tb := range tables {
out.Write(tb.data)
for out.Len()%4 != 0 {
out.WriteByte(0)
}
}
return out.Bytes()
}
// 解析出的码点 → 字形 → 码点映射与构造时一致。
func TestQueryTTFParseRoundTrip(t *testing.T) {
data := buildTestTTF(t, map[rune]uint16{
'A': 1, 'B': 2, 'C': 3, 'D': 4,
})
font, err := parseQueryTTFFont(data)
if err != nil {
t.Fatalf("解析字体失败: %v", err)
}
for cp, gid := range map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4} {
if got := font.unicodeToGlyphID[cp]; got != gid {
t.Fatalf("码点 %q 的字形下标 = %d,期望 %d", cp, got, gid)
}
}
if font.glyphToUnicode[font.unicodeToGlyph['A']] != 'A' {
t.Fatal("字形 → 码点映射不正确")
}
if font.unicodeToGlyph['A'] == font.unicodeToGlyph['B'] {
t.Fatal("不同码点的轮廓不应相同")
}
}
// replaceFont:错误字体把 A 渲染成 B 的字形,正确字体应把 A 还原成 B。
func TestReplaceFontRestoresText(t *testing.T) {
// 错误字体:码点 A 指向字形 2(也就是 B 的形状)。
errorFontData := buildTestTTF(t, map[rune]uint16{'A': 2, 'B': 3, 'C': 4, 'D': 5})
// 正确字体:码点 B 指向字形 2。
correctFontData := buildTestTTF(t, map[rune]uint16{'A': 1, 'B': 2, 'C': 3, 'D': 4})
errorFont, err := parseQueryTTFFont(errorFontData)
if err != nil {
t.Fatal(err)
}
correctFont, err := parseQueryTTFFont(correctFontData)
if err != nil {
t.Fatal(err)
}
// 页面上写的是 'A',实际字形是 B → 应还原为 'B'。
got := replaceFontText("A", errorFont, correctFont, false)
if got != "B" {
t.Fatalf("replaceFont 还原结果 = %q,期望 %q", got, "B")
}
// 空白与未知码点保持原样。
mixed := replaceFontText("A 中", errorFont, correctFont, false)
if !strings.HasPrefix(mixed, "B ") || !strings.HasSuffix(mixed, "中") {
t.Fatalf("混合文本处理异常: %q", mixed)
}
// filter=true 时删掉没有对应字形的字符。
filtered := replaceFontText("A中", errorFont, correctFont, true)
if filtered != "B" {
t.Fatalf("filter 结果 = %q,期望 %q", filtered, "B")
}
}
// 坏字体只应报错,不能 panic。
func TestQueryTTFBadFontNoPanic(t *testing.T) {
cases := [][]byte{
nil,
[]byte("not a font"),
[]byte("ttcf"),
append([]byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x02}, make([]byte, 40)...),
}
for i, data := range cases {
if _, err := parseQueryTTFFont(data); err == nil {
t.Fatalf("坏字体 #%d 应返回错误", i)
}
}
// 截断的合法字体也不能 panic。
full := buildTestTTF(t, map[rune]uint16{'A': 1})
for cut := 1; cut < len(full); cut += 37 {
_, _ = parseQueryTTFFont(full[:cut])
}
}
+52
View File
@@ -339,6 +339,58 @@ func findParamSplit(s string) (start, end int, ok bool) {
return 0, 0, false
}
// FindParamSplit 导出 findParamSplit:图片等媒体地址也支持 ",{...}" 选项段,
// 服务层需要先拆分再分别做请求与展示处理。
func FindParamSplit(s string) (start, end int, ok bool) {
return findParamSplit(s)
}
// MediaOptions 是媒体地址尾部 ",{...}" 选项段里服务端允许应用的部分。
//
// 图片/音频等媒体请求走代理,选项里只有请求头与重试这类「取图必需」的字段有意义;
// webView/webJs/js/bodyJs 这些需要浏览器或脚本执行引擎的字段必须忽略,
// 否则一个书源就能让媒体代理变成任意请求的中转。
type MediaOptions struct {
Headers map[string]string
Charset string
Retry *int
Method string
}
// ParseMediaOptions 拆分媒体地址与其尾部选项。
// 返回去掉选项段的地址(已 trim)、应用后的选项与「是否带选项」。
func ParseMediaOptions(raw string) (string, MediaOptions, bool) {
var out MediaOptions
s := strings.TrimSpace(raw)
st, end, ok := findParamSplit(s)
if !ok {
return s, out, false
}
base := strings.TrimSpace(s[:st])
optionStr := strings.TrimSpace(s[end:])
var option URLOption
if err := json.Unmarshal([]byte(optionStr), &option); err != nil {
if err2 := json.Unmarshal([]byte(strings.TrimPrefix(optionStr, ",")), &option); err2 != nil {
return base, out, false
}
}
if len(option.Headers) > 0 {
out.Headers = make(map[string]string, len(option.Headers))
for k, v := range option.Headers {
out.Headers[k] = anyToString(v)
}
}
out.Charset = option.Charset
out.Retry = option.Retry
switch strings.ToUpper(option.Method) {
case "HEAD":
out.Method = "HEAD"
default:
out.Method = "GET"
}
return base, out, true
}
func isSpaceByte(c byte) bool {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\v' || c == '\f'
}
@@ -0,0 +1,301 @@
package reader
import (
"bytes"
"compress/gzip"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/text/encoding/htmlindex"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件覆盖规则稳定性修复的回归:
// - @put 写回书源变量(跨请求持久化)
// - book.putVariable 持久化到书架记录
// - 书源正则的匹配超时(灾难性回溯不会挂死)
// - helper.DecompressBody 的解压上限
// - 非 UTF-8 TXT 的导入/读取
// - 图片 URL 尾部 options 的请求头应用
// @put 必须写到书源变量(legado BaseSource.putVariable),而不是一次性的书籍变量。
func TestPutPersistsToSourceVariable(t *testing.T) {
svc, _ := newLoginTestService(t)
srcURL := "https://put.example.com"
src := &model.ReaderBookSource{Name: "put 源", SourceURL: srcURL}
bs := &BookSource{BookSourceURL: srcURL, RuleContent: &ContentRule{Content: strPtr("id.content@textNodes")}}
sess := svc.newSession(t.Context(), src, bs)
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
ar.Put("token", "abc123")
sess.close()
// 换一个全新会话/解析器:@put 的值必须还能 @get 到(已落库)。
sess2 := svc.newSession(t.Context(), src, bs)
defer sess2.close()
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
if got := ar2.Get("token"); got != "abc123" {
t.Fatalf("@put 未持久化:@get 得到 %q", got)
}
}
// 书源 variables 默认值只是兜底:@put 写入的同名值优先级更高。
func TestPutOverridesSourceDefaultVariable(t *testing.T) {
svc, _ := newLoginTestService(t)
srcURL := "https://put2.example.com"
raw := `{"bookSourceUrl":"https://put2.example.com","variables":"{\"token\":\"default\"}"}`
srcID := importTestSource(t, svc, raw, srcURL)
src, err := svc.repo.GetSource(context.Background(), srcID)
if err != nil {
t.Fatal(err)
}
bs, err := ParseBookSource(src.RawJSON)
if err != nil {
t.Fatal(err)
}
sess := svc.newSession(t.Context(), src, bs)
ar := sess.newAnalyzer("", 0, "<html></html>", srcURL)
if got := ar.Get("token"); got != "default" {
t.Fatalf("默认变量应可读,得到 %q", got)
}
ar.Put("token", "override")
sess.close()
sess2 := svc.newSession(t.Context(), src, bs)
defer sess2.close()
ar2 := sess2.newAnalyzer("", 0, "<html></html>", srcURL)
if got := ar2.Get("token"); got != "override" {
t.Fatalf("@put 应覆盖默认变量,得到 %q", got)
}
}
// book.putVariable 写入的变量要落回书架记录(对应 legado Book.upVariable)。
func TestBookPutVariablePersists(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
book.Variable = `{"tone_id":"keep"}`
if err := svc.repo.UpdateBook(ctx, book); err != nil {
t.Fatal(err)
}
loaded, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
src, bs, err := svc.loadSourceFlexible(ctx, "", book.Origin)
if err != nil {
t.Fatal(err)
}
sess := svc.newSession(ctx, src, bs)
sess.book = loaded
ar := sess.newAnalyzer("", 0, "<html></html>", book.Origin)
sess.applyBookContext(ar, book.BookURL, loaded, "", 0)
// 书源 JS 的 book.getVariable / putVariable。
runner := sess.runner("", 0)
if _, err := runner.Run(ar, `book.putVariable('token','v-42'); book.getVariable('tone_id')`, nil, book.Origin); err != nil {
t.Fatalf("book JS 执行失败: %v", err)
}
sess.close()
stored, err := svc.repo.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
vars := parseBookVariableMap(stored.Variable)
if vars["token"] != "v-42" {
t.Fatalf("book.putVariable 未持久化: %q", stored.Variable)
}
if vars["tone_id"] != "keep" {
t.Fatalf("已有变量被覆盖: %q", stored.Variable)
}
_ = srv
}
// 灾难性回溯的正则必须在秒级返回,而不是挂死 goroutine。
func TestRegexAnalyzerTimeoutOnCatastrophicBacktracking(t *testing.T) {
// (a+)+$ 对 "aaaa...b" 是指数级回溯;没有 MatchTimeout 会永久卡住。
evil := strings.Repeat("a", 40) + "b"
done := make(chan string, 1)
go func() {
done <- rule.ApplyReplaceRegexString(evil, "##(a+)+$##X")
}()
select {
case <-done:
case <-time.After(15 * time.Second):
t.Fatal("书源正则未在超时预算内返回(MatchTimeout 未生效)")
}
}
// 解压炸弹:超过输出上限时原样返回压缩字节(调用方按失败处理),不撑爆内存。
func TestDecompressBodyRejectsBomb(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
// 128MB 的零字节压成几十 KB。
chunk := make([]byte, 1<<20)
for i := 0; i < 128; i++ {
if _, err := gz.Write(chunk); err != nil {
t.Fatal(err)
}
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
out := helper.DecompressBody(resp, buf.Bytes())
if len(out) == len(chunk)*128 {
t.Fatal("解压炸弹应被上限拦截")
}
if !bytes.Equal(out, buf.Bytes()) {
t.Fatal("超限时应原样返回压缩字节")
}
}
// 正常 gzip 响应仍应被正确解压(上限不误伤常规页面)。
func TestDecompressBodyNormal(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
payload := []byte("<html>hello</html>")
if _, err := gz.Write(payload); err != nil {
t.Fatal(err)
}
_ = gz.Close()
resp := &http.Response{Header: http.Header{"Content-Encoding": []string{"gzip"}}}
if out := helper.DecompressBody(resp, buf.Bytes()); string(out) != string(payload) {
t.Fatalf("正常 gzip 解压失败: %q", out)
}
}
// 非 UTF-8(GBK)TXT:导入落盘为 UTF-8,按章节读取不再乱码/错位。
func TestImportGBKTextBookReadsCorrectly(t *testing.T) {
// 用临时 DataDir,避免本地书籍文件写进仓库目录。
svc := newLocalBookService(t)
ctx := t.Context()
// 用 GBK 编码两章正文(解码后章节字节区间按 UTF-8 计算)。
gbkBytes := func(s string) []byte {
enc, err := htmlindex.Get("gbk")
if err != nil {
t.Skip("环境缺少 GBK 编码支持")
}
out, err := enc.NewEncoder().Bytes([]byte(s))
if err != nil {
t.Fatal(err)
}
return out
}
text := "第一章 起点\n这是第一章的正文内容,包含中文标点。\n第二章 继续\n这是第二章的正文内容。\n"
book, err := svc.ImportLocalBook(ctx, "u1", "gbk小说.txt", gbkBytes(text))
if err != nil {
t.Fatalf("导入 GBK 书籍失败: %v", err)
}
if book.Charset != "gbk" {
t.Fatalf("应识别为 gbk,实际 %q", book.Charset)
}
chapters, err := svc.ListChapters(ctx, book.ID)
if err != nil || len(chapters) < 2 {
t.Fatalf("应切出至少 2 章: n=%d err=%v", len(chapters), err)
}
second, err := svc.readLocalChapter(book, chapters[1])
if err != nil {
t.Fatalf("读取第二章失败: %v", err)
}
if !strings.Contains(second, "第二章的正文内容") {
t.Fatalf("第二章内容不正确(编码错位): %q", second)
}
}
// 图片 URL 尾部 options:拆分出请求头,且地址本身去掉选项段。
func TestParseMediaOptions(t *testing.T) {
raw := `https://img.example.com/a.jpg,{"headers":{"Referer":"https://site.example.com/","X-Token":"t1"},"retry":2}`
base, opt, ok := rule.ParseMediaOptions(raw)
if !ok {
t.Fatal("应识别出选项段")
}
if base != "https://img.example.com/a.jpg" {
t.Fatalf("基础地址错误: %q", base)
}
if opt.Headers["Referer"] != "https://site.example.com/" || opt.Headers["X-Token"] != "t1" {
t.Fatalf("请求头解析错误: %+v", opt.Headers)
}
if opt.Retry == nil || *opt.Retry != 2 {
t.Fatalf("retry 解析错误: %+v", opt.Retry)
}
// 无选项段的普通地址原样返回。
if b, _, ok := rule.ParseMediaOptions("https://img.example.com/b.jpg"); ok || b != "https://img.example.com/b.jpg" {
t.Fatalf("无选项地址不应被改写: %q ok=%v", b, ok)
}
}
// 代理地址改写后,图片选项头必须在媒体请求里真正生效。
func TestFetchMediaAppliesOptionHeaders(t *testing.T) {
var gotReferer, gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReferer = r.Header.Get("Referer")
gotToken = r.Header.Get("X-Token")
w.Header().Set("Content-Type", "image/jpeg")
_, _ = w.Write([]byte("jpeg-bytes"))
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
book := &model.ReaderBook{Base: model.Base{ID: "b1"}, Origin: srv.URL, BookURL: srv.URL + "/book/1"}
resp, err := svc.FetchMediaWithOptions(t.Context(), book, srv.URL+"/a.jpg", "", map[string]string{
"Referer": "https://site.example.com/",
"X-Token": "t1",
})
if err != nil {
t.Fatalf("拉取失败: %v", err)
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if gotReferer != "https://site.example.com/" || gotToken != "t1" {
t.Fatalf("选项请求头未生效: referer=%q token=%q", gotReferer, gotToken)
}
}
// ProxyURL 去掉选项段后再签名:带选项与不带选项的同一张图得到同一签名。
func TestProxyURLStripsMediaOptions(t *testing.T) {
svc, _ := newLoginTestService(t)
plain := svc.ProxyURL("book-1", "https://img.example.com/a.jpg")
withOptions := svc.ProxyURL("book-1", `https://img.example.com/a.jpg,{"headers":{"Referer":"https://s/"}}`)
if plain != withOptions {
t.Fatalf("选项段应被剥离后签名:\n%s\n%s", plain, withOptions)
}
raw, err := svc.VerifyProxyURL("book-1", strings.TrimPrefix(strings.Split(plain, "&u=")[1], ""), "")
if err == nil && raw != "" {
_ = raw
}
// 拆出 u/s 校验签名可回推出干净地址。
u := plain[strings.Index(plain, "&u=")+3:]
if i := strings.Index(u, "&s="); i >= 0 {
u = u[:i]
}
if decoded, err := svc.VerifyProxyURL("book-1", u, plain[strings.Index(plain, "&s=")+3:]); err != nil || decoded != "https://img.example.com/a.jpg" {
t.Fatalf("签名校验失败: %q err=%v", decoded, err)
}
}
// HLS 判定:带 query 的 m3u8 与 Content-Type 不标准都要识别;普通图片不能被误判。
func TestLooksLikeHLSURL(t *testing.T) {
cases := map[string]bool{
"https://a.com/index.m3u8": true,
"https://a.com/index.m3u8?token=xx": true,
"https://a.com/live/index.M3U": true,
"https://a.com/a.mp3": false,
"https://a.com/m3u8/1": false,
}
for raw, want := range cases {
if got := looksLikeHLSURL(raw); got != want {
t.Fatalf("looksLikeHLSURL(%q)=%v,期望 %v", raw, got, want)
}
}
}
+2 -2
View File
@@ -57,7 +57,7 @@ func TestSearchUsesRequestedPage(t *testing.T) {
{0, "1"}, // 非法页码退回第 1 页
{-3, "1"}, // 负数同理
} {
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
books, skipped, err := svc.Search(t.Context(), "u1", "关键词", nil, tc.in)
if err != nil {
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
}
@@ -88,7 +88,7 @@ func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
books, _, err := svc.Search(t.Context(), "书", nil, 3)
books, _, err := svc.Search(t.Context(), "u1", "书", nil, 3)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
books, skipped, err := svc.Search(t.Context(), "u1", "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅", nil, 1)
books, skipped, err := svc.Search(t.Context(), "u1", "宠魅", nil, 1)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out))
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
if _, _, err := svc.Search(t.Context(), "u1", "宠魅", nil, 1); err != nil {
t.Fatalf("搜索失败: %v", err)
}
if gotQuery != "宠魅" {
+5 -5
View File
@@ -53,11 +53,11 @@ func TestSearchRespectsSourceScope(t *testing.T) {
scopeEchoSourceJSON(t, "源A", srvA.URL),
scopeEchoSourceJSON(t, "源B", srvB.URL),
} {
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
if _, err := svc.ImportSources(t.Context(), "u1", raw); err != nil {
t.Fatal(err)
}
}
sources, err := svc.ListSources(t.Context())
sources, err := svc.ListSources(t.Context(), "u1")
if err != nil {
t.Fatal(err)
}
@@ -75,7 +75,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 指定单个源:只应搜到该源的结果
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
books, skipped, err := svc.Search(t.Context(), "u1", "书", []string{idByName["源A"]}, 1)
if err != nil {
t.Fatal(err)
}
@@ -87,7 +87,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 空范围:默认全部启用书源,两个源都应命中
books, _, err = svc.Search(t.Context(), "书", nil, 1)
books, _, err = svc.Search(t.Context(), "u1", "书", nil, 1)
if err != nil {
t.Fatal(err)
}
@@ -96,7 +96,7 @@ func TestSearchRespectsSourceScope(t *testing.T) {
}
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
books, _, err = svc.Search(t.Context(), "u1", "书", []string{"not-a-real-source"}, 1)
if err != nil {
t.Fatal(err)
}
+8 -2
View File
@@ -28,7 +28,10 @@ type BookSource struct {
LoginCheckJS *string `json:"loginCheckJs"`
JSLib *string `json:"jsLib"`
BookSourceComment *string `json:"bookSourceComment"`
LastUpdateTime *int64 `json:"lastUpdateTime"`
// CoverDecodeJs 封面 bytes 二次解密 JS(返回解密后的字节),
// 对应 legado BookSource.coverDecodeJs。规则为空时封面零开销直通。
CoverDecodeJs *string `json:"coverDecodeJs"`
LastUpdateTime *int64 `json:"lastUpdateTime"`
RespondTime *int64 `json:"respondTime"`
Weight *int `json:"weight"`
ExploreURL *string `json:"exploreUrl"`
@@ -151,7 +154,10 @@ type ContentRule struct {
SourceRegex *string `json:"sourceRegex"`
ReplaceRegex *string `json:"replaceRegex"`
ImageStyle *string `json:"imageStyle"`
PayAction *string `json:"payAction"`
// ImageDecode 正文图片 bytes 二次解密 JS(返回解密后的字节),
// 对应 legado ContentRule.imageDecode。
ImageDecode *string `json:"imageDecode"`
PayAction *string `json:"payAction"`
}
// ExploreRule 发现规则。
@@ -38,7 +38,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
imported, err := svc.ImportSources(ctx, messySourceJSON)
imported, err := svc.ImportSources(ctx, "u1", messySourceJSON)
if err != nil {
t.Fatalf("导入失败: %v", err)
}
@@ -46,7 +46,7 @@ func TestImportSourcesAcceptsMessySource(t *testing.T) {
t.Fatalf("导入数量 = %d,期望 1", imported)
}
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
bs, err := svc.repo.GetSourceByURL(ctx, "u1", "https://www.mangacopy.com/")
if err != nil || bs == nil {
t.Fatalf("按 URL 查不到导入的书源: %v", err)
}
@@ -139,7 +139,7 @@ func TestParseBookSourceRejectsGarbage(t *testing.T) {
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
svc, _ := newLoginTestService(t)
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
_, err := svc.ImportSources(t.Context(), "u1", `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
if err == nil {
t.Fatal("一条都没导入时应当返回错误")
}
@@ -0,0 +1,110 @@
package reader
import (
"strings"
"testing"
)
// 书源按用户独立:同一 URL 的两个用户各自持有副本,启停/删除/搜索互不影响。
func TestSourcesArePerUser(t *testing.T) {
srv := newCacheTestServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
raw := cacheTestSourceJSON(srv.URL)
if _, err := svc.ImportSources(ctx, "u1", raw); err != nil {
t.Fatalf("u1 导入失败: %v", err)
}
// 第二个用户导入同一书源 URL:旧实现会撞 source_url 唯一索引(导入失败)。
if _, err := svc.ImportSources(ctx, "u2", raw); err != nil {
t.Fatalf("u2 导入同一书源失败(书源未按用户隔离): %v", err)
}
u1Sources, err := svc.ListSources(ctx, "u1")
if err != nil || len(u1Sources) != 1 {
t.Fatalf("u1 应有 1 个书源: n=%d err=%v", len(u1Sources), err)
}
u2Sources, err := svc.ListSources(ctx, "u2")
if err != nil || len(u2Sources) != 1 {
t.Fatalf("u2 应有 1 个书源: n=%d err=%v", len(u2Sources), err)
}
if u1Sources[0].ID == u2Sources[0].ID {
t.Fatal("两个用户应各持一份副本(行 ID 不同)")
}
if u1Sources[0].UserID != "u1" || u2Sources[0].UserID != "u2" {
t.Fatalf("书源归属错误: u1=%q u2=%q", u1Sources[0].UserID, u2Sources[0].UserID)
}
// 启停互不影响。
if err := svc.UpdateSourceEnabled(ctx, "u1", u1Sources[0].ID, false); err != nil {
t.Fatalf("u1 停用失败: %v", err)
}
u2After, _ := svc.ListSources(ctx, "u2")
if len(u2After) != 1 || !u2After[0].Enabled {
t.Fatal("u1 停用影响到了 u2 的书源")
}
// 删除只删自己那份。
if err := svc.DeleteSource(ctx, "u1", u1Sources[0].ID); err != nil {
t.Fatalf("u1 删除失败: %v", err)
}
if left, _ := svc.ListSources(ctx, "u1"); len(left) != 0 {
t.Fatalf("u1 应已无书源: n=%d", len(left))
}
if left, _ := svc.ListSources(ctx, "u2"); len(left) != 1 {
t.Fatalf("u2 的书源被误删: n=%d", len(left))
}
// 越权删除他人书源必须失败。
if err := svc.DeleteSource(ctx, "u1", u2Sources[0].ID); err == nil {
t.Fatal("u1 不应能删除 u2 的书源")
}
}
// 搜索只使用当前用户的书源范围。
func TestSearchUsesOwnSourcesOnly(t *testing.T) {
srv := e2eServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
if _, err := svc.ImportSources(ctx, "u1", e2eSourceJSON(srv.URL)); err != nil {
t.Fatal(err)
}
// u2 没有任何书源:搜索应直接报「没有已启用的书源」,而不是用 u1 的源。
if _, _, err := svc.Search(ctx, "u2", "书", nil, 1); err == nil || !strings.Contains(err.Error(), "没有已启用的书源") {
t.Fatalf("u2 无书源时应拒绝搜索,实际 err=%v", err)
}
// u1 自己的搜索正常。
books, _, err := svc.Search(ctx, "u1", "书", nil, 1)
if err != nil {
t.Fatalf("u1 搜索失败: %v", err)
}
if len(books) == 0 {
t.Fatal("u1 应搜到结果")
}
}
// 同一书源 URL 的两个用户副本:删除再导入仍然可用(物理删除释放唯一键)。
func TestDeleteAndReimportSameSource(t *testing.T) {
srv := newCacheTestServer()
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
if _, err := svc.ImportSources(ctx, "u1", cacheTestSourceJSON(srv.URL)); err != nil {
t.Fatal(err)
}
sources, _ := svc.ListSources(ctx, "u1")
if err := svc.DeleteSource(ctx, "u1", sources[0].ID); err != nil {
t.Fatalf("删除失败: %v", err)
}
if _, err := svc.ImportSources(ctx, "u1", cacheTestSourceJSON(srv.URL)); err != nil {
t.Fatalf("删除后重新导入失败: %v", err)
}
if again, _ := svc.ListSources(ctx, "u1"); len(again) != 1 {
t.Fatalf("重新导入后应有 1 个书源: n=%d", len(again))
}
}
@@ -11,10 +11,10 @@ import (
// importTestSource 导入一个测试书源并按书源 URL 找到它(同包测试可能导入多个源)。
func importTestSource(t *testing.T, svc *ReaderService, raw, sourceURL string) string {
t.Helper()
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
if _, err := svc.ImportSources(t.Context(), "u1", raw); err != nil {
t.Fatalf("导入书源失败: %v", err)
}
srcs, err := svc.ListSources(t.Context())
srcs, err := svc.ListSources(t.Context(), "u1")
if err != nil {
t.Fatal(err)
}
@@ -46,7 +46,7 @@ func TestGetTocFollowsNextTocUrl(t *testing.T) {
svc, _ := newLoginTestService(t)
tocURL := srv.URL + "/toc?limit=2&offset=0"
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL, nil)
if err != nil {
t.Fatalf("取目录失败: %v", err)
}
@@ -106,7 +106,7 @@ list
}
svc, _ := newLoginTestService(t)
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0", nil)
if err != nil {
t.Fatalf("取目录失败: %v", err)
}
@@ -146,7 +146,7 @@ func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
}
svc, _ := newLoginTestService(t)
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc", nil)
if err != nil {
t.Fatalf("取目录失败: %v", err)
}
+18
View File
@@ -43,6 +43,9 @@ type SchedulerService struct {
imagesPolicyProvider func() ImageCachePolicy
// readerContentCleaner 阅读正文缓存的清理钩子(由阅读模块注入)。
readerContentCleaner func(context.Context)
segments *MediaSegmentService
mu sync.Mutex
@@ -86,6 +89,13 @@ func (s *SchedulerService) imageCachePolicy() ImageCachePolicy {
return ImageCachePolicy{}
}
// SetReaderContentCleaner 注入阅读正文缓存的清理钩子(TTL 过期 + 容量 LRU)。
// 阅读模块自身持有缓存目录与索引(见 reader 包的 pruneReaderContentCache),
// 调度器只负责按小时触发;未注入时不注册该任务。
func (s *SchedulerService) SetReaderContentCleaner(fn func(context.Context)) {
s.readerContentCleaner = fn
}
// scheduledJob is one recurring task.
type scheduledJob struct {
name string
@@ -148,6 +158,14 @@ func (s *SchedulerService) Start(ctx context.Context) {
run: s.jobCleanImageCache,
},
}
// 阅读正文缓存清理只在注入钩子后注册(见 SetReaderContentCleaner)。
if s.readerContentCleaner != nil {
s.jobs = append(s.jobs, &scheduledJob{
name: "reader_content_cleanup",
interval: 1 * time.Hour,
run: s.jobCleanReaderContentCache,
})
}
// 片头预热只在注入了 Segments 时注册,避免测试跑无转外网任务。
if s.segments != nil {
s.jobs = append(s.jobs, &scheduledJob{
+9
View File
@@ -177,6 +177,15 @@ func (s *SchedulerService) jobTelegramExpiryWarning(ctx context.Context) error {
return s.expiryWatcher.RunOnce(ctx)
}
// jobCleanReaderContentCache 触发阅读正文缓存的 TTL / 容量淘汰。
func (s *SchedulerService) jobCleanReaderContentCache(ctx context.Context) error {
if s.readerContentCleaner == nil {
return nil
}
s.readerContentCleaner(ctx)
return nil
}
// jobCleanTranscodeCache deletes HLS artefacts older than 24h.
func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error {
if s.cacheDir == "" {
+4
View File
@@ -210,6 +210,10 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() {
}
return policy
})
// 阅读正文缓存清理:由阅读模块自己按 TTL + 容量 LRU 淘汰(见 reader_content_cache.go)。
if b.c.Reader != nil {
b.c.Scheduler.SetReaderContentCleaner(b.c.Reader.PruneContentCache)
}
}
func (b *serviceContainerBuilder) initIdentityServices() {
+33
View File
@@ -122,6 +122,29 @@ export interface ReaderChapterContent {
comments?: ReaderContentComment[]
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
transcoding?: boolean
/** 该音轨是 HLS 播放列表(m3u8),前端据此走 hls.js 而不是原生 audio。 */
hls?: boolean
}
/** 批量取正文的单章结果(对应服务端 ContentBatchItem)。 */
export interface ReaderContentBatchItem {
chapter_index: number
type?: 'text' | 'audio' | 'image'
content?: string
tracks?: string[]
images?: string[]
image_style?: string
hls?: boolean
comments?: ReaderContentComment[]
/** 由服务端持久缓存直接命中(本次未访问书源)。 */
cached?: boolean
error?: string
}
/** 批量取正文的汇总(对应服务端 ContentBatchResult)。 */
export interface ReaderContentBatchResult {
items: ReaderContentBatchItem[]
stats: { hit: number; miss: number; failed: number }
}
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
@@ -378,6 +401,16 @@ export const readerAPI = {
})
.then((r) => r.data),
// 批量取正文(窗口预取 / 离线缓存):单章失败不中断整批,命中持久缓存的章节零网络开销。
bookContentBatch: (id: string, chapterIndexes: number[], signal?: AbortSignal) =>
api
.post<ReaderContentBatchResult>(
`/reader/books/${id}/content-batch`,
{ chapter_indexes: chapterIndexes },
{ timeout: LONG_REQUEST_TIMEOUT, signal },
)
.then((r) => r.data),
// ── 替换净化规则 ──
listReplaceRules: () =>
api.get<{ rules: ReaderReplaceRule[] }>('/reader/replace-rules').then((r) => r.data.rules),
+29
View File
@@ -0,0 +1,29 @@
import { decideDoubleTap } from './useDoubleTap.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`useDoubleTap: ${name}`)
}
// 空闲时的第一下:进入等待窗口,单击不立刻触发(留给定时器)。
const first = decideDoubleTap('none', 'tap')
check('first tap opens a window', first.window === 'tap')
check('first tap does not fire immediately', first.fire === null)
// 窗口里的第二下:判成双击并立刻触发(必须在事件里同步执行,见实现注释)。
const second = decideDoubleTap('tap', 'tap')
check('second tap fires double', second.fire === 'double')
check('second tap closes the window', second.window === 'none')
// 菜单遮罩关菜单:只开窗口,不触发单击,也不影响状态语义。
const armed = decideDoubleTap('none', 'arm')
check('arm opens a window', armed.window === 'armed')
check('arm never fires a single', armed.fire === null)
// 关掉菜单后紧接的第二下要算双击,否则菜单一开一关就永远进不了全屏。
check('tap after arm fires double', decideDoubleTap('armed', 'tap').fire === 'double')
// 已经在双击窗口里再 arm:续期,窗口保持 armed。
check('arm during window stays armed', decideDoubleTap('armed', 'arm').window === 'armed')
check('arm during tap window stays armed', decideDoubleTap('tap', 'arm').window === 'armed')
console.log('useDoubleTap.test.ts ok')
+134
View File
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef } from 'react'
/** 两次点击间隔在这个毫秒数以内算一次双击。 */
export const DOUBLE_TAP_MS = 280
/**
* 双击窗口的状态。
*
* - `none` 空闲,没有待判定的点击
* - `tap` 刚单击过一次,还等第二下;窗口期满仍没有第二下就触发单击
* - `armed` 只开了双击窗口(菜单遮罩关掉菜单时用),期满什么也不触发
*/
export type DoubleTapWindow = 'none' | 'tap' | 'armed'
/** 一次动作立刻要触发的效果;null 表示只更新窗口状态、不立刻触发(留给定时器)。 */
export type DoubleTapFire = 'single' | 'double' | null
export interface DoubleTapDecision {
window: DoubleTapWindow
fire: DoubleTapFire
}
/**
* 双击窗口的纯决策(不碰计时器,便于单测):给定当前窗口状态和这次动作,
* 得出窗口进入什么状态、要不要立刻触发回调。
*
* `tap` = 用户点了一下中间区域;`arm` = 只开窗口不算点击(关菜单时调用)。
*/
export function decideDoubleTap(window: DoubleTapWindow, action: 'tap' | 'arm'): DoubleTapDecision {
if (action === 'arm') {
// 只开窗口:已在窗口里就续期,否则新开一个 armed 窗口,永远不触发单击。
return { window: 'armed', fire: null }
}
// 已经有窗口(无论是单击待定的 tap 还是刚 arm 的)→ 第二下到达,判成双击。
if (window !== 'none') return { window: 'none', fire: 'double' }
// 第一下:进入 tap 窗口等第二下,单击由窗口期满的定时器触发。
return { window: 'tap', fire: null }
}
/**
* 中间区域的「单击 / 双击」识别(手机上用来区分呼出菜单和进出全屏)。
*
* 中间区域原先单击就是呼出菜单,现在双击还要切全屏,两个动作落在同一块屏幕上,
* 就得先等一小会儿看有没有第二下:第一下把窗口置为 `tap`,第二下在窗口内到达就
* 取消单击、改判双击。**双击回调必须在第二次点击的事件里同步执行**——浏览器只认
* 用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。
*
* 返回三个入口:
* - `tap()`:分区点击。`enabled` 为假(桌面端,或 iOS 这种不支持元素全屏的浏览器)
* 时不做任何延迟,单击立刻生效,端上呼出菜单的手感与改动前完全一致。
* - `arm()`:只开一个双击窗口、不排单击。菜单打开时整屏遮罩会先吃掉那次点击
* (见 ReaderViewPage 的关闭遮罩),遮罩把菜单关掉的同时调它,紧跟着的第二下
* 才能落进窗口被判成双击,而不是又当成单击把菜单重新打开。
* - `cancel()`:丢掉待判定的点击。用户点了中间又立刻去翻页时,别让那次单击
* 在窗口期满时把菜单弹出来。
*/
export function useDoubleTap({
onDoubleTap,
onSingleTap,
enabled,
delay = DOUBLE_TAP_MS,
}: {
onDoubleTap: () => void
onSingleTap: () => void
enabled: boolean
delay?: number
}): { tap: () => void; arm: () => void; cancel: () => void } {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const windowRef = useRef<DoubleTapWindow>('none')
// 回调每次渲染都可能是新闭包,用 ref 兜住,免得已排队的定时器跟着旧闭包一起失效。
const doubleRef = useRef(onDoubleTap)
const singleRef = useRef(onSingleTap)
useEffect(() => {
doubleRef.current = onDoubleTap
singleRef.current = onSingleTap
})
const clear = useCallback(() => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}, [])
/** 取消待判定的点击并回到空闲。 */
const cancel = useCallback(() => {
clear()
windowRef.current = 'none'
}, [clear])
/** 开一个双击窗口:期满后,tap 窗口触发单击,armed 窗口什么也不做。 */
const startWindow = useCallback(
(kind: DoubleTapWindow) => {
clear()
windowRef.current = kind
timerRef.current = setTimeout(() => {
timerRef.current = null
const wasTap = windowRef.current === 'tap'
windowRef.current = 'none'
if (wasTap) singleRef.current()
}, delay)
},
[clear, delay],
)
// 卸载时清掉待执行的单击,避免离开页面后还蹦一下。
useEffect(() => cancel, [cancel])
// 双击被关掉(如切到桌面布局)时,丢弃已排队的点击。
useEffect(() => {
if (!enabled) cancel()
}, [enabled, cancel])
const tap = useCallback(() => {
if (!enabled) {
singleRef.current()
return
}
const decision = decideDoubleTap(windowRef.current, 'tap')
if (decision.fire === 'double') {
// 第二下:取消待定的单击,改判双击(同步执行,保住用户手势上下文)。
cancel()
doubleRef.current()
return
}
startWindow(decision.window)
}, [enabled, cancel, startWindow])
const arm = useCallback(() => {
if (!enabled) return
startWindow(decideDoubleTap(windowRef.current, 'arm').window)
}, [enabled, startWindow])
return { tap, arm, cancel }
}
+28
View File
@@ -0,0 +1,28 @@
import { useEffect, useState } from 'react'
// 按设备能力判断「手机/平板」:没有真正的鼠标悬停能力的就是触摸设备。
// 应用里已有同样的做法(PlayerControls / Vr360Stage 的 isHoverlessDevice),
// 这里做成 hook:媒体查询在字体缩放、外接鼠标插拔、chrome 设备模拟时会变化,
// 跟随 change 事件回写,比渲染时读一次更稳。
const HOVERLESS_QUERY = '(hover: none)'
function queryHoverless(): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
return window.matchMedia(HOVERLESS_QUERY).matches
}
/** 当前设备是否是触摸设备(手机/平板)。桌面端(带鼠标)返回 false。 */
export function useIsTouchDevice(): boolean {
const [touch, setTouch] = useState(queryHoverless)
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
const media = window.matchMedia(HOVERLESS_QUERY)
const onChange = () => setTouch(media.matches)
onChange()
media.addEventListener('change', onChange)
return () => media.removeEventListener('change', onChange)
}, [])
return touch
}
+2
View File
@@ -252,8 +252,10 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
const rect = e.currentTarget.getBoundingClientRect()
const x = (e.clientX - rect.left) / rect.width
if (x < 0.3) {
onZone('left')
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
} else if (x > 0.7) {
onZone('right')
scrollRef.current?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
} else {
onZone('center')
+148 -20
View File
@@ -9,7 +9,9 @@ import {
LayoutList,
ListEnd,
Loader2,
Maximize,
MessageSquare,
Minimize,
Minus,
Moon,
Plus,
@@ -28,11 +30,14 @@ import {
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useDoubleTap } from '../../hooks/useDoubleTap'
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
import { useIsTouchDevice } from '../../hooks/useIsTouchDevice'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { formatScrollPct, shouldSaveScrollProgress } from '../../utils/readerScrollProgress'
import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
@@ -62,6 +67,13 @@ const IMG_MARK = '[img]'
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
const AUDIO_SAVE_INTERVAL_MS = 5_000
/**
* 正文窗口预取章数:当前章之后预取 3 章。
* 单章串行预取在高延迟书源上常常赶不上连续翻章;批量接口在服务端并发抓取并落
* 持久缓存(对应 legado CacheBook),翻到后续章时直接命中。
*/
const PREFETCH_WINDOW = 3
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
const COMIC_SAVE_INTERVAL_MS = 2_000
@@ -269,6 +281,9 @@ export default function ReaderViewPage() {
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
// 浏览器全屏(沉浸式):把阅读层整个铺满屏幕,连浏览器的地址栏/状态栏一起让出来。
// 页面本身是 fixed inset-0,所以这里要解决的是浏览器自身的界面,而不是页面内的留白。
const [fullscreen, setFullscreen] = useState(false)
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
const [tocGroupIndex, setTocGroupIndex] = useState(0)
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
@@ -346,6 +361,52 @@ export default function ReaderViewPage() {
return () => window.removeEventListener('resize', onResize)
}, [])
// ── 浏览器全屏(沉浸式) ──
// 只认标准 API:iOS 上的 Safari 至今不支持元素全屏(只有 <video> 能全屏),
// 所以那里不显示按钮(不是灰掉——按钮点了没反应会被当成坏了)。
const fullscreenSupported = useMemo(
() => typeof document !== 'undefined' && document.fullscreenEnabled === true,
[],
)
useEffect(() => {
const onFullscreenChange = () => setFullscreen(Boolean(document.fullscreenElement))
document.addEventListener('fullscreenchange', onFullscreenChange)
return () => document.removeEventListener('fullscreenchange', onFullscreenChange)
}, [])
// 离开阅读页时退出全屏:否则返回书架后浏览器仍停在全屏,整站都被罩住。
useEffect(
() => () => {
if (document.fullscreenElement) void document.exitFullscreen()
},
[],
)
const toggleFullscreen = useCallback(() => {
const el = readerRef.current
if (!el) return
if (document.fullscreenElement) {
void document.exitFullscreen()
return
}
// 进全屏是为了看正文,菜单还压在上面就没意义了,先收起来。
setMenuOpen(false)
setPanel('none')
// 浏览器可能以权限策略等理由拒绝,静默忽略即可(按钮状态由 fullscreenchange 回写)。
void el.requestFullscreen?.().catch(() => {})
}, [])
// ── 手机端:中间区域双击进出全屏 ──
// 只在「触摸设备 + 浏览器支持元素全屏」时启用:桌面端不装这套(点击不带任何延迟,
// 保持原来的操作手感),iOS Safari 不支持元素全屏(见上)也照旧只留单击呼出菜单。
// 区分单击/双击的等待与「第二下同步调 requestFullscreen」的约束都在 useDoubleTap 里。
const touchDevice = useIsTouchDevice()
const doubleTapFullscreen = fullscreenSupported && touchDevice
const { tap: centerTap, arm: armCenterDoubleTap, cancel: cancelCenterTap } = useDoubleTap({
// 单击仍是呼出/收起菜单,双击改成进出浏览器全屏。
onSingleTap: () => setMenuOpen((v) => !v),
onDoubleTap: toggleFullscreen,
enabled: doubleTapFullscreen,
})
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
const comicDoublePage =
contentType === 'image' &&
@@ -514,6 +575,7 @@ export default function ReaderViewPage() {
chapters,
chapterIndex,
track: ct.tracks[0],
hls: ct.hls ?? false,
transcoding: ct.transcoding ?? false,
openCredits: book.open_credits ?? 0,
closeCredits: book.close_credits ?? 0,
@@ -526,14 +588,34 @@ export default function ReaderViewPage() {
readerAPI
.saveProgress(book.id, { chapter_index: chapterIndex, pos: savedPos, chapter_title: ch.title })
.catch(() => undefined)
// 预取下一章
if (!contentCache.current.has(contentCacheKey(chapterIndex + 1))) {
// 窗口预取:往后取几章(默认 3),一次批量请求由服务端并发抓取并落持久缓存。
// 批量接口单章失败不影响其它章,命中的章节在服务端零网络开销。
const prefetchIndexes: number[] = []
for (let i = 1; i <= PREFETCH_WINDOW; i++) {
const idx = chapterIndex + i
if (idx >= chapters.length) break
if (chapters[idx]?.is_volume) continue
if (contentCache.current.has(contentCacheKey(idx))) continue
prefetchIndexes.push(idx)
}
if (prefetchIndexes.length > 0) {
readerAPI
.bookContent(book.id, chapterIndex + 1, ac.signal)
.then((c) => {
.bookContentBatch(book.id, prefetchIndexes, ac.signal)
.then((result) => {
// 预取是在「旧源」发起、在换源后才回来的话,结果属于脏数据,丢掉。
if (cancelled) return
contentCache.current.set(contentCacheKey(chapterIndex + 1), c)
for (const item of result.items) {
if (item.error || !item.type) continue
contentCache.current.set(contentCacheKey(item.chapter_index), {
type: item.type as 'text' | 'audio' | 'image',
content: item.content,
tracks: item.tracks,
images: item.images,
image_style: item.image_style,
hls: item.hls,
comments: item.comments,
})
}
})
.catch(() => undefined)
}
@@ -828,15 +910,22 @@ export default function ReaderViewPage() {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// 滚动模式恢复进度
// 滚动模式恢复进度。
//
// 同时清掉 dataset.last:它是「上次保存的滚动百分比」,换章后必须重算。
// 否则新章首屏若与上一章末尾的百分比差值不到 2%,首次滚动会被节流掉,
// 退出后进度又回到旧位置。
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
const el = scrollRef.current
if (el && pendingPosRef.current > 0) {
el.scrollTop = pendingPosRef.current
pendingPosRef.current = 0
if (el) {
delete el.dataset.last
if (pendingPosRef.current > 0) {
el.scrollTop = pendingPosRef.current
pendingPosRef.current = 0
}
}
}, [content, settings.pageMode])
}, [content, chapterIndex, settings.pageMode])
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
@@ -849,6 +938,8 @@ export default function ReaderViewPage() {
const savePos = useCallback(
(pos: number) => {
if (!book || chapterIndex === null) return
// 滚动位置读自 DOM,异常时可能是 NaN;落库前挡掉,避免把进度写坏。
if (!Number.isFinite(pos)) return
const ch = chapters[chapterIndex]
readerAPI
.saveProgress(book.id, {
@@ -1183,11 +1274,18 @@ export default function ReaderViewPage() {
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
if (x < 0.3) goPrev()
else if (x > 0.7) goNext()
else setMenuOpen((v) => !v)
if (x < 0.3) {
cancelCenterTap()
goPrev()
} else if (x > 0.7) {
cancelCenterTap()
goNext()
} else {
// 中间单击=菜单、双击=全屏,区分逻辑在 useDoubleTap(桌面端原样单击)。
centerTap()
}
},
[goPrev, goNext],
[goPrev, goNext, centerTap, cancelCenterTap],
)
// ── 键盘(桌面端) ──
@@ -1382,7 +1480,13 @@ export default function ReaderViewPage() {
}
return (
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
<div
ref={readerRef}
className="fixed inset-0 z-40 flex flex-col"
// 手机上双击中间切全屏时,别让浏览器把自己的「双击缩放」也做一遍:
// touch-action:manipulation 关掉双击缩放,纵向滚动与双指缩放照常。
style={{ backgroundColor: theme.bg, color: theme.text, touchAction: doubleTapFullscreen ? 'manipulation' : undefined }}
>
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
@@ -1405,7 +1509,9 @@ export default function ReaderViewPage() {
draggable={comicDragSinglePage}
stageRef={comicStageRef}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
if (zone === 'center') centerTap()
// 点了左右分区(翻屏)就丢掉待判定的中间单击,别让它在窗口期满时弹菜单。
else cancelCenterTap()
}}
initialImage={restorePos}
onProgress={(idx) => {
@@ -1463,8 +1569,8 @@ export default function ReaderViewPage() {
const max = el.scrollHeight - el.clientHeight
if (max > 0) {
const pct = el.scrollTop / max
if (Math.abs(pct * 1000 - (Number(el.dataset.last) ?? -1) * 1000) > 20) {
el.dataset.last = String(pct)
if (shouldSaveScrollProgress(pct, el.dataset.last)) {
el.dataset.last = formatScrollPct(pct)
savePos(Math.round(el.scrollTop))
}
}
@@ -1533,6 +1639,7 @@ export default function ReaderViewPage() {
aria-label="上一页"
onClick={() => {
if (consumeSwipe()) return
cancelCenterTap()
goPrev()
}}
className="cursor-w-resize"
@@ -1542,7 +1649,8 @@ export default function ReaderViewPage() {
aria-label="菜单"
onClick={() => {
if (consumeSwipe()) return
setMenuOpen((v) => !v)
// 中间单击=菜单、双击=全屏,区分逻辑在 useDoubleTap(桌面端原样单击)。
centerTap()
}}
className="cursor-default"
/>
@@ -1551,6 +1659,7 @@ export default function ReaderViewPage() {
aria-label="下一页"
onClick={() => {
if (consumeSwipe()) return
cancelCenterTap()
goNext()
}}
className="cursor-e-resize"
@@ -1576,7 +1685,16 @@ export default function ReaderViewPage() {
type="button"
aria-label="关闭菜单"
className="fixed inset-0 z-40 cursor-default bg-black/30"
onClick={() => setMenuOpen(false)}
onClick={(e) => {
// 关菜单要立刻生效,不套单击延迟。
setMenuOpen(false)
// 但若这一下关在中间区域,就顺手开个双击窗口:菜单开着时双击中间
// 也能切全屏(第二下落在刚关掉菜单的正文上,见 useDoubleTap.arm)。
// 点在左右三分区(本意是翻页)时不开窗口,免得被下一拍误判成双击。
const rect = e.currentTarget.getBoundingClientRect()
const x = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0.5
if (x >= 0.3 && x <= 0.7) armCenterDoubleTap()
}}
/>
{/* 顶栏 */}
<div
@@ -1659,6 +1777,16 @@ export default function ReaderViewPage() {
},
]
: []),
// 全屏:只在浏览器支持元素全屏时出现(iOS Safari 不支持,见上方注释)。
...(fullscreenSupported
? [
{
icon: fullscreen ? <Minimize size={18} /> : <Maximize size={18} />,
label: fullscreen ? '还原' : '全屏',
action: toggleFullscreen,
},
]
: []),
...(canSwitchSource
? [
{
+21 -10
View File
@@ -31,6 +31,12 @@ export interface LoadChapterInput {
chapterIndex: number
/** 当前章的音轨地址(已签名)。 */
track: string
/**
* 音轨是否为 HLS 播放列表(服务端下发)。
* 代理地址是 base64 的 /api/reader/media?u=...,前端无法再从后缀判断格式,
* 必须用这个标志决定走 hls.js 还是原生 <audio>。
*/
hls?: boolean
transcoding: boolean
openCredits: number
closeCredits: number
@@ -65,7 +71,7 @@ class ReaderAudioEngine {
/** 加载批次号:动态 import hls.js 期间被新的加载打断时用来丢弃旧结果。 */
private loadToken = 0
/** 预取缓存:章序号 → 已解析的音轨。命中的切章可以同步开播,不用等网络。 */
private prefetched = new Map<number, { track: string; transcoding: boolean }>()
private prefetched = new Map<number, { track: string; transcoding: boolean; hls: boolean }>()
/** 预取在途的章序号,避免同一章重复请求。 */
private prefetching = new Set<number>()
/** 预热下一章字节的隐藏播放器(只下载,从不 play())。 */
@@ -141,7 +147,7 @@ class ReaderAudioEngine {
if (preset > 0) this.applyTimer(preset)
}
void this.attachSource(input.track)
void this.attachSource(input.track, input.hls === true)
// 提前解析下一章的音轨并在后台预热字节:息屏自动续播时才能立刻接上
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex !== null) this.prefetchChapter(nextIndex)
@@ -258,7 +264,11 @@ class ReaderAudioEngine {
// ─── 音源装载 ─────────────────────────────────────────────────────────
private async attachSource(src: string): Promise<void> {
/**
* 装上音源。isHLS 由服务端判定(见 LoadChapterInput.hls)——
* 代理地址不含 .m3u8 后缀,前端不能再用地址猜格式。
*/
private async attachSource(src: string, isHLS: boolean): Promise<void> {
const token = ++this.loadToken
// 只销毁上一章的 hls,不再 removeAttribute('src') + load()。load() 会把媒体
// 元素彻底卸载、释放系统音频会话,息屏时紧接着的 play() 就会被浏览器当成
@@ -276,7 +286,7 @@ class ReaderAudioEngine {
// 先停住上一章:HLS 分支要 await 动态 import,不先停会有一小段两章叠播
if (!this.audio.paused) this.audio.pause()
this.setSessionMetadata(src)
if (src.includes('.m3u8')) {
if (isHLS) {
const mod = await import('hls.js')
// 动态 import 期间可能已经切到别的章:丢弃这次结果
if (token !== this.loadToken) return
@@ -329,19 +339,19 @@ class ReaderAudioEngine {
if (useReaderAudioStore.getState().bookId !== forBook) return
const track = ct.tracks?.[0]
if (ct.type !== 'audio' || !track) return
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false })
this.prefetched.set(index, { track, transcoding: ct.transcoding ?? false, hls: ct.hls ?? false })
// 要服务端转码的音轨立刻预热(把转码提前跑掉);直链等到本章快播完再拉字节,
// 避免用户中途停下时白下整章的流量。
if (ct.transcoding) this.warmNextBytes(track)
if (ct.transcoding) this.warmNextBytes(track, ct.hls ?? false)
})
.catch(() => undefined)
.finally(() => this.prefetching.delete(index))
}
/** 用隐藏播放器把下一章的字节(以及服务端转码)提前拉起来,减少切章等待。 */
private warmNextBytes(track: string): void {
private warmNextBytes(track: string, isHLS: boolean): void {
// HLS 交给 hls.js 按需拉流,二次下载没有意义
if (!track || track.includes('.m3u8') || this.warmedTrack === track) return
if (!track || isHLS || this.warmedTrack === track) return
this.warmedTrack = track
if (!this.warmer) {
const a = new Audio()
@@ -359,7 +369,7 @@ class ReaderAudioEngine {
const nextIndex = this.neighborChapterIndex(1)
if (nextIndex === null) return
const hit = this.prefetched.get(nextIndex)
if (hit) this.warmNextBytes(hit.track)
if (hit) this.warmNextBytes(hit.track, hit.hls)
}
/** 用预取结果接续下一章;命中返回 true(未命中由调用方走异步加载兜底)。 */
@@ -375,6 +385,7 @@ class ReaderAudioEngine {
chapters: s.chapters,
chapterIndex: index,
track: hit.track,
hls: hit.hls,
transcoding: hit.transcoding,
openCredits: s.openCredits,
closeCredits: s.closeCredits,
@@ -383,7 +394,7 @@ class ReaderAudioEngine {
// 立刻在 ended 的同一次事件里发起 play(),保证没有空档。片头跳转由
// loadedmetadata 处理器完成:音频在 loadedmetadata 之前不会出声,所以
// 先 play() 也不会漏出片头。HLS 交给 hls.js 承载,无法同帧开播。
if (!hit.track.includes('.m3u8')) this.play()
if (!hit.hls) this.play()
return true
}
@@ -0,0 +1,39 @@
import {
SCROLL_SAVE_THRESHOLD_PCT,
formatScrollPct,
parseLastScrollPct,
shouldSaveScrollProgress,
} from './readerScrollProgress.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`readerScrollProgress: ${name}`)
}
// 回归:dataset.last 缺失时 Number(undefined) 是 NaN,旧实现用 `?? -1` 接不住,
// 比较恒为 false,滚动进度永远不保存。首次滚动必须触发。
check('missing dataset triggers the first save', shouldSaveScrollProgress(0.1, undefined) === true)
// 空串同样要按「没有历史值」处理。
check('empty dataset triggers save', shouldSaveScrollProgress(0.1, '') === true)
// 变化不足阈值不保存(避免每帧都上报)。
check('small delta is throttled', shouldSaveScrollProgress(0.101, '0.1') === false)
// 达到阈值保存。
check('threshold delta saves', shouldSaveScrollProgress(0.13, '0.1') === true)
check('backward scroll saves', shouldSaveScrollProgress(0.07, '0.1') === true)
// 边界:正好等于阈值。
const justAt = 0.1 + SCROLL_SAVE_THRESHOLD_PCT / 100
check('exactly at threshold saves', shouldSaveScrollProgress(justAt, '0.1') === true)
// 非法输入不触发保存。
check('non-finite current pct is ignored', shouldSaveScrollProgress(NaN, '0.1') === false)
check('corrupt dataset value is treated as missing', shouldSaveScrollProgress(0.5, 'not-a-number') === true)
// 解析与格式化往返。
check('parse keeps finite values', parseLastScrollPct('0.25') === 0.25)
check('parse rejects garbage', Number.isNaN(parseLastScrollPct('x')))
check('format round-trips', parseLastScrollPct(formatScrollPct(0.375)) === 0.375)
console.log('readerScrollProgress tests passed')
+38
View File
@@ -0,0 +1,38 @@
/**
* 滚动阅读的进度保存节流。
*
* 只按滚动百分比的变化幅度决定是否上报,避免每帧都发保存请求。
* 状态存在 DOM 的 dataset 上(滚动是热路径,不走 React state)。
*/
/** 触发保存的最小百分比变化(2%)。 */
export const SCROLL_SAVE_THRESHOLD_PCT = 2
/**
* 读取上次保存的滚动百分比。
*
* 必须用 Number.isFinite 判断:dataset 缺失时 Number(undefined) 得到 NaN,
* 而 NaN 与任何值比较都是 false,会导致首次滚动永远不保存(进度丢失)。
*/
export function parseLastScrollPct(raw: string | undefined): number {
const n = Number.parseFloat(raw ?? '')
return Number.isFinite(n) ? n : NaN
}
/**
* 当前滚动百分比是否应该触发保存。
*
* @param currentPct 当前滚动百分比(0~1)
* @param lastRaw 上次保存的原始值(dataset.last)
*/
export function shouldSaveScrollProgress(currentPct: number, lastRaw: string | undefined): boolean {
if (!Number.isFinite(currentPct)) return false
const last = parseLastScrollPct(lastRaw)
if (!Number.isFinite(last)) return true
return Math.abs(currentPct - last) * 100 >= SCROLL_SAVE_THRESHOLD_PCT
}
/** 把百分比格式化成保存用的字符串。 */
export function formatScrollPct(pct: number): string {
return String(pct)
}