mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-15 20:34:23 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67ef9c95be | |||
| 2bf1b27e23 | |||
| 48a1c36a7a | |||
| d7665e4ef0 | |||
| 99916c0b97 | |||
| 6adc3cb3b2 | |||
| 2b2dc697ce | |||
| 8bfdd75d47 | |||
| 1a3248f4bb | |||
| 0be7f8b97e | |||
| 6984158f24 | |||
| d545ff1a7b | |||
| 227e0a9197 | |||
| e85df5fb6b | |||
| 6656f9c398 | |||
| dacfec7492 | |||
| b0f1ab39b9 | |||
| 5242d54a06 | |||
| e961969207 | |||
| b8b02cb3a7 | |||
| 60cfad47e5 | |||
| 47c8bfdd6c | |||
| ce3baee8f1 | |||
| 2494f0c0cd | |||
| c4e9926352 | |||
| f3b88fad7c | |||
| 2c6646beea | |||
| 83ec494a36 | |||
| b8648f0049 | |||
| 5acfa6292a |
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -76,7 +77,15 @@ func main() {
|
||||
fatal("未从输入中识别到书源")
|
||||
}
|
||||
|
||||
svc := reader.NewReaderService(&config.Config{}, zap.NewNop(), &repository.Container{})
|
||||
// 书源文件缓存根目录:java.downloadFile / cacheFile 需要它才能落盘,
|
||||
// 否则「拷贝轻小说」这类把整卷文本缓存到本地再读的源会被判为不支持。
|
||||
// 用固定子目录而不是每次 MkdirTemp,避免反复运行在临时目录里留一堆垃圾。
|
||||
cfg := &config.Config{}
|
||||
cacheDir := filepath.Join(os.TempDir(), "reader-smoke-cache")
|
||||
if err := os.MkdirAll(cacheDir, 0o750); err == nil {
|
||||
cfg.Cache.CacheDir = cacheDir
|
||||
}
|
||||
svc := reader.NewReaderService(cfg, zap.NewNop(), &repository.Container{})
|
||||
ctx := context.Background()
|
||||
|
||||
results := make([]*reader.SmokeChainResult, len(sources))
|
||||
|
||||
+34
-2
@@ -26,6 +26,29 @@
|
||||
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
|
||||
- `/reader/*` 本来就是独立全屏布局(不套影视 Layout),不受影响。
|
||||
|
||||
**MeBox 书架已实现的布局与信息(`ReaderHomeContent.tsx` + `BookshelfCard.tsx` + `BookshelfSettingsDialog.tsx`)**
|
||||
- 三套布局,设置在顶栏「书架设置」弹窗里(对应 legado dialog_bookshelf_config),存 `stores/readerSettings.ts`:
|
||||
- 网格:封面宫格,列数 2–6 或「自适应」;窄屏自动降到 2–3 列兜底。卡片含封面 + 本地角标 + 未读章数徽标 + 双行书名 + 详情/移出按钮。
|
||||
- 列表:66×90 封面 + 书名 + 作者 + 更新时间 + 读到(当前章)+ 最新章节;对应 legado `item_bookshelf_list.xml` 的四行信息。
|
||||
- 紧凑列表:48×64 小封面 + 书名 +「作者 · 读到」,一屏放更多书(对应 legado `layout_list_compact`)。
|
||||
- 排序六档,对齐 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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载。
|
||||
|
||||
**书架分组(`/reader/book-groups`,仿影视模块的媒体库标签)**
|
||||
- 按用户存服务端:`reader_book_groups` 表每个用户一条,`groups` 列是 `[{name, book_ids}]` 的 JSON。结构、语义与影视的媒体库标签(`User.LibraryTags`)同构——组名 → 成员 ID、整份替换、一个成员只归一个组、组内顺序即展示顺序。
|
||||
- 接口:`GET /reader/book-groups`(没有分组返回空数组)、`PUT /reader/book-groups`(整份替换)。服务端落库前做两条需要「知道书是否存在」的收敛:只保留该用户书架上的书(书移出书架后分组里不留死 ID)、一本书只归一个组(越靠前的分组优先)。空分组保留,方便先建组再放书。
|
||||
- 与影视的差异:书架有两个内置页签「全部」「未分组」(未分组是「没有被任何分组认领」的视图,不持久化,只有存在分组时才出现,且固定在分组栏最后,避免挤开用户自己拖拽排好的分组顺序);书籍不进管理弹窗逐个分配,而是在卡片上用「分组」按钮指定(书架可能有上千本,列出来既慢又难找)。
|
||||
- 前端:`utils/readerBookGroups.ts`(纯逻辑)+ `hooks/useBookGroups.ts`(乐观更新、串行提交、失败回滚,接口不可用时退回 localStorage,照搬 `useLibraryTags`)+ `components/BookGroupBar.tsx`(分组栏)+ `ManageBookGroupsDialogView.tsx`(新建/重命名/删除/拖拽排序)+ `BookGroupPickerDialog.tsx`(移到分组)。
|
||||
|
||||
**阅读器偏好按账号同步(跨设备)**
|
||||
- 主题 / 夜间模式 / 排版(字号、行距、段距)/ 翻页或滚动模式 / 听书倍速与定时 / 书架布局·排序·显示项,原先只存浏览器 localStorage(设备级,换设备就丢),现在按用户落库到 `reader_profiles`(`model.ReaderProfile`,`user_id` 唯一索引),接口 `GET|PUT /reader/profile`。
|
||||
- 服务端是权威来源,localStorage(zustand persist 的 `mebox-reader-settings`)退化成首屏缓存:登录后 `hydrateReaderSettings()` 拉一次覆盖本地,之后本地改动防抖 600ms 回写(`utils/readerSettingsSync.ts`)。
|
||||
- 账号还没有偏好记录时 `GET` 返回 `null`,前端把本地现值推上去「播种」,升级前在本机调好的设置不会被重置。
|
||||
- 服务端只做存储 + 范围收敛(数值夹区间、枚举未知回落默认、主题标识限长),不做语义解释;前端另有本地 clamp,两处范围需同步(见 `service/reader/profile.go` 顶部注释)。
|
||||
- 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不参与同步。
|
||||
|
||||
## 2. 阅读界面(重点)
|
||||
|
||||
文件:`<src>ui/book/read/`(ReadBookActivity、ReadMenu、SearchMenu、MangaMenu、config/*Dialog)、`ui/book/read/page/`(ReadView、PageView、ContentTextView、ChapterProvider)。
|
||||
@@ -71,7 +94,15 @@
|
||||
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
|
||||
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
|
||||
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
|
||||
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
|
||||
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
|
||||
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
|
||||
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
|
||||
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
|
||||
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
|
||||
- 漫画图片显示尺寸(`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+滚轮` 保留浏览器缩放。
|
||||
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
|
||||
|
||||
**本地书籍(对应 legado 本地 TXT / EPUB)**
|
||||
@@ -91,7 +122,7 @@
|
||||
- 定时关闭:0/5/10/15/30/60/90/180 分钟;暂停期间不倒计时;归零自动暂停播放;选定值持久化为下次默认(对应 AppConfig.ttsTimer 在服务启动时 setTimer)。
|
||||
- 倍速:滑杆 0.5–3.0(步进 0.1)+ 0.5/0.75/1/1.25/1.5/1.75/2/2.5/3 快捷档;持久化(对应 AudioPlay.playSpeed,Android 6 以下不支持调速)。
|
||||
- 跳过片头片尾:抽屉内两条滑杆(片头/片尾,秒,0 不跳过,上限 300),按书持久化(Book.openCredits/closeCredits,落库 books.open_credits / close_credits)。语义:全新开播(该章进度为 0)时 seek 到片头秒数;播放到 duration-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
|
||||
- 播放进度按秒记忆(节流 10s 上报),跨端一致;播完自动下一章。
|
||||
- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
|
||||
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
|
||||
|
||||
## 3. 搜索
|
||||
@@ -221,4 +252,5 @@
|
||||
- 正文分页仿 ReadView 三页缓存 + 9 宫格点击映射。
|
||||
- 主题模型照抄 ReadBookConfig.Config(含日/夜/E-Ink 三态字段),内置 6 套主题色值直接使用上表。
|
||||
- 搜索并发用 Promise 池 + 流式合并,合并逻辑照抄 mergeItems 四档排序。
|
||||
- 搜索分页:滚到底自动取下一页(`POST /reader/search` 的 `page`,从 1 开始,对应书源 `searchUrl` 的 `{{page}}`),逐页增量合并(跨页按「书名+作者」去重、并书源,见 `utils/searchBooks.ts`)。收口比 legado 严一档:**新一页没带来新书就停止翻页**——不支持分页的源每页都重复返回首页结果,照 legado「任一源有结果即可翻页」会一直转圈加载;单次搜索最多 20 页。
|
||||
- 书源/替换编辑器按第 7/8 节 Tab/字段一一对应。
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
|
||||
//
|
||||
// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
|
||||
// 这里只钉住契约与安全边界,避免把测试绑到网络。
|
||||
func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
|
||||
container := newReaderHandlerContainer(t)
|
||||
router := registerReaderRoutesForTest(container)
|
||||
|
||||
post := func(body string) int {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"缺少 url", `{"book_id":"x"}`},
|
||||
{"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
|
||||
{"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
|
||||
{"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if code := post(c.body); code != http.StatusBadRequest {
|
||||
t.Fatalf("%s:应 400,得到 %d", c.name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 书架分组接口(/reader/book-groups)的 HTTP 契约测试。
|
||||
//
|
||||
// 分组仿影视模块的媒体库标签:整份替换、一本书只归一个组、按用户隔离。
|
||||
// 另外两条是书籍特有的:只保留书架上的书(书移出后不留死 ID)、空分组保留。
|
||||
|
||||
func addBookForGroups(t *testing.T, svc *service.Container, userID, name, bookURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book, err := svc.Reader.AddBook(t.Context(), userID, reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: bookURL,
|
||||
}, name, "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
func setBookGroups(t *testing.T, router *gin.Engine, user string, groups []model.BookGroupSet) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{"groups": groups})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/book-groups", strings.NewReader(string(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func getBookGroups(t *testing.T, router *gin.Engine, user string) ([]model.BookGroupSet, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Groups, w.Code
|
||||
}
|
||||
|
||||
func namesOf(groups []model.BookGroupSet) string {
|
||||
out := make([]string, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
out = append(out, g.Name)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func idsOf(groups []model.BookGroupSet, name string) string {
|
||||
for _, g := range groups {
|
||||
if g.Name == name {
|
||||
return strings.Join(g.BookIDs, ",")
|
||||
}
|
||||
}
|
||||
return "<missing>"
|
||||
}
|
||||
|
||||
// TestBookGroupsEmptyStateIsArray 没有分组时应返回空数组,前端可直接遍历。
|
||||
func TestBookGroupsEmptyStateIsArray(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/book-groups", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
// 必须是 [] 而不是 null
|
||||
if !strings.Contains(w.Body.String(), `"groups":[]`) {
|
||||
t.Fatalf("空态应返回 [],实际 %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsNormalizeFilterAndDedupe(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
b3 := addBookForGroups(t, svc, "u1", "书三", "https://example.com/3")
|
||||
foreign := addBookForGroups(t, svc, "u2", "别人的书", "https://example.com/9")
|
||||
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
{Name: "在读", BookIDs: []string{b1.ID}}, // b1 已被「科幻」认领 → 应变空
|
||||
{Name: "玄幻", BookIDs: []string{b3.ID}},
|
||||
{Name: "空组", BookIDs: []string{}},
|
||||
{Name: " ", BookIDs: []string{b2.ID}}, // 空名 → 丢弃整个分组
|
||||
{Name: "外来", BookIDs: []string{foreign.ID}}, // 不属于 u1 的书架 → 过滤掉
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "科幻,在读,玄幻,空组,外来" {
|
||||
t.Fatalf("分组顺序/数量 = %q,期望 科幻,在读,玄幻,空组,外来", names)
|
||||
}
|
||||
if ids := idsOf(got, "科幻"); ids != b1.ID+","+b2.ID {
|
||||
t.Fatalf("科幻 = %q,期望 %s,%s", ids, b1.ID, b2.ID)
|
||||
}
|
||||
if ids := idsOf(got, "在读"); ids != "" {
|
||||
t.Fatalf("在读 应为空(b1 归了科幻),实际 %q", ids)
|
||||
}
|
||||
if ids := idsOf(got, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
if ids := idsOf(got, "外来"); ids != "" {
|
||||
t.Fatalf("外来 应被过滤为空,实际 %q", ids)
|
||||
}
|
||||
|
||||
// 读回来应当一致
|
||||
loaded, code := getBookGroups(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("读回 status=%d", code)
|
||||
}
|
||||
if names := namesOf(loaded); names != namesOf(got) {
|
||||
t.Fatalf("读回分组 = %q,期望 %q", names, namesOf(got))
|
||||
}
|
||||
if ids := idsOf(loaded, "玄幻"); ids != b3.ID {
|
||||
t.Fatalf("读回 玄幻 = %q,期望 %s", ids, b3.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsDropsBooksRemovedFromShelf 书被移出书架后,分组里不应留下死 ID。
|
||||
func TestBookGroupsDropsBooksRemovedFromShelf(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
b2 := addBookForGroups(t, svc, "u1", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "科幻", BookIDs: []string{b1.ID, b2.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
if err := svc.Reader.RemoveBook(t.Context(), "u1", b2.ID); err != nil {
|
||||
t.Fatalf("移出书架失败: %v", err)
|
||||
}
|
||||
|
||||
loaded, _ := getBookGroups(t, router, "u1")
|
||||
if ids := idsOf(loaded, "科幻"); ids != b1.ID {
|
||||
t.Fatalf("科幻 = %q,期望只剩 %s", ids, b1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBookGroupsArePerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
u1Book := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
u2Book := addBookForGroups(t, svc, "u2", "书二", "https://example.com/2")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "u1 的分组", BookIDs: []string{u1Book.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u2 看不到 u1 的分组
|
||||
if groups, code := getBookGroups(t, router, "u2"); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
|
||||
// u2 把 u1 的书塞进自己的分组也不会生效(不属于 u2 的书架)
|
||||
if groups, code := setBookGroups(t, router, "u2", []model.BookGroupSet{
|
||||
{Name: "抢过来", BookIDs: []string{u1Book.ID, u2Book.ID}},
|
||||
}); code != http.StatusOK || idsOf(groups, "抢过来") != u2Book.ID {
|
||||
t.Fatalf("u2 分组 = %+v (status=%d),期望只含自己的书", groups, code)
|
||||
}
|
||||
|
||||
// u1 的分组不受影响
|
||||
back, _ := getBookGroups(t, router, "u1")
|
||||
if names := namesOf(back); names != "u1 的分组" || idsOf(back, "u1 的分组") != u1Book.ID {
|
||||
t.Fatalf("u1 分组被 u2 影响: %+v", back)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBookGroupsSaveTwiceReplacesWholeSet 整份替换语义:第二次保存覆盖第一次。
|
||||
func TestBookGroupsSaveTwiceReplacesWholeSet(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
b1 := addBookForGroups(t, svc, "u1", "书一", "https://example.com/1")
|
||||
|
||||
if _, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "旧组", BookIDs: []string{b1.ID}},
|
||||
}); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
got, code := setBookGroups(t, router, "u1", []model.BookGroupSet{
|
||||
{Name: "新组", BookIDs: []string{}},
|
||||
})
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
if names := namesOf(got); names != "新组" {
|
||||
t.Fatalf("二次保存后 = %q,期望只有 新组", names)
|
||||
}
|
||||
|
||||
// 清空分组
|
||||
if groups, code := setBookGroups(t, router, "u1", nil); code != http.StatusOK || len(groups) != 0 {
|
||||
t.Fatalf("清空后 = %+v (status=%d),期望空", groups, code)
|
||||
}
|
||||
if groups, _ := getBookGroups(t, router, "u1"); len(groups) != 0 {
|
||||
t.Fatalf("清空后读回 = %+v,期望空", groups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读器偏好接口(/reader/profile)的 HTTP 契约测试。
|
||||
//
|
||||
// 这些设置原先只存在浏览器 localStorage(设备级),换设备就丢。现在按用户落库,
|
||||
// 所以要保证:新用户没有记录、写读一致、越界值收敛、以及用户之间互不影响。
|
||||
|
||||
// profileRouterForTest 用请求头 X-Test-User 指定调用者(默认 u1),
|
||||
// 便于在一个测试里切换用户验证隔离。
|
||||
func profileRouterForTest(t *testing.T, cfg *service.Container) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
user := c.GetHeader("X-Test-User")
|
||||
if user == "" {
|
||||
user = "u1"
|
||||
}
|
||||
c.Set(middleware.CtxUserID, user)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func getProfile(t *testing.T, router *gin.Engine, user string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/profile", nil)
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func putProfile(t *testing.T, router *gin.Engine, user, body string) (*reader.ReaderSettings, int) {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/profile", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Test-User", user)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
return nil, w.Code
|
||||
}
|
||||
var res struct {
|
||||
Profile *reader.ReaderSettings `json:"profile"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析响应失败: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
return res.Profile, w.Code
|
||||
}
|
||||
|
||||
func TestReaderProfileNullBeforeFirstSave(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
profile, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status=%d", code)
|
||||
}
|
||||
// 没有记录时返回 null,由前端用它本地的值播种
|
||||
if profile != nil {
|
||||
t.Fatalf("新用户应返回 null,实际 %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileRoundTrip(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
body := `{
|
||||
"theme_id":"preset2","night":true,"page_mode":"scroll",
|
||||
"font_size":24,"line_height":2.1,"paragraph_spacing":12,
|
||||
"audio_speed":1.5,"audio_timer_minutes":30,
|
||||
"shelf_layout":"compact","shelf_grid_columns":4,"shelf_sort":"update",
|
||||
"shelf_show_unread":false,"shelf_show_update_time":true
|
||||
}`
|
||||
saved, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if saved == nil {
|
||||
t.Fatal("保存应返回偏好")
|
||||
}
|
||||
|
||||
got, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || got == nil {
|
||||
t.Fatalf("读回失败 status=%d profile=%+v", code, got)
|
||||
}
|
||||
if *got != *saved {
|
||||
t.Fatalf("读回与写入不一致:\n写入 %+v\n读回 %+v", *saved, *got)
|
||||
}
|
||||
if got.ThemeID != "preset2" || !got.Night || got.PageMode != "scroll" || got.FontSize != 24 ||
|
||||
got.LineHeight != 2.1 || got.ParagraphSpacing != 12 || got.AudioSpeed != 1.5 ||
|
||||
got.AudioTimerMinutes != 30 || got.ShelfLayout != "compact" || got.ShelfGridColumns != 4 ||
|
||||
got.ShelfSort != "update" || got.ShelfShowUnread || !got.ShelfShowUpdateTime {
|
||||
t.Fatalf("字段未按原值往返: %+v", *got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileClampsAndFallsBack(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
// 全部越界 / 枚举非法,服务端应收敛而不是原样入库
|
||||
body := `{
|
||||
"theme_id":" ","night":false,"page_mode":"nope",
|
||||
"font_size":999,"line_height":0.1,"paragraph_spacing":-5,
|
||||
"audio_speed":99,"audio_timer_minutes":9999,
|
||||
"shelf_layout":"diagonal","shelf_grid_columns":42,"shelf_sort":"random",
|
||||
"shelf_show_unread":true,"shelf_show_update_time":false
|
||||
}`
|
||||
got, code := putProfile(t, router, "u1", body)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("保存失败 status=%d", code)
|
||||
}
|
||||
if got.ThemeID != "preset1" {
|
||||
t.Fatalf("空主题应回落到 preset1,实际 %q", got.ThemeID)
|
||||
}
|
||||
if got.PageMode != "page" {
|
||||
t.Fatalf("非法 page_mode 应回落到 page,实际 %q", got.PageMode)
|
||||
}
|
||||
if got.FontSize != 32 {
|
||||
t.Fatalf("font_size 应夹到上限 32,实际 %d", got.FontSize)
|
||||
}
|
||||
if got.LineHeight != 1.4 {
|
||||
t.Fatalf("line_height 应夹到下限 1.4,实际 %v", got.LineHeight)
|
||||
}
|
||||
if got.ParagraphSpacing != 0 {
|
||||
t.Fatalf("paragraph_spacing 应夹到下限 0,实际 %d", got.ParagraphSpacing)
|
||||
}
|
||||
if got.AudioSpeed != 3 {
|
||||
t.Fatalf("audio_speed 应夹到上限 3,实际 %v", got.AudioSpeed)
|
||||
}
|
||||
if got.AudioTimerMinutes != 180 {
|
||||
t.Fatalf("audio_timer_minutes 应夹到上限 180,实际 %d", got.AudioTimerMinutes)
|
||||
}
|
||||
if got.ShelfLayout != "grid" {
|
||||
t.Fatalf("非法 shelf_layout 应回落到 grid,实际 %q", got.ShelfLayout)
|
||||
}
|
||||
if got.ShelfGridColumns != 6 {
|
||||
t.Fatalf("shelf_grid_columns 应夹到上限 6,实际 %d", got.ShelfGridColumns)
|
||||
}
|
||||
if got.ShelfSort != "recent" {
|
||||
t.Fatalf("非法 shelf_sort 应回落到 recent,实际 %q", got.ShelfSort)
|
||||
}
|
||||
// 布尔值不参与收敛,必须原样保留
|
||||
if !got.ShelfShowUnread || got.ShelfShowUpdateTime {
|
||||
t.Fatalf("布尔项被改动: unread=%v update=%v", got.ShelfShowUnread, got.ShelfShowUpdateTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileIsPerUser(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
if _, code := putProfile(t, router, "u1", `{
|
||||
"theme_id":"preset3","night":true,"page_mode":"page","font_size":18,
|
||||
"line_height":1.5,"paragraph_spacing":4,"audio_speed":1,
|
||||
"audio_timer_minutes":0,"shelf_layout":"list","shelf_grid_columns":0,
|
||||
"shelf_sort":"name","shelf_show_unread":true,"shelf_show_update_time":true
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u1 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// 另一个用户不应看到 u1 的偏好,也不应在 u1 之外建出记录
|
||||
other, code := getProfile(t, router, "u2")
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("u2 读取 status=%d", code)
|
||||
}
|
||||
if other != nil {
|
||||
t.Fatalf("u2 应返回 null,实际 %+v", other)
|
||||
}
|
||||
|
||||
if _, code := putProfile(t, router, "u2", `{
|
||||
"theme_id":"preset4","night":false,"page_mode":"scroll","font_size":20,
|
||||
"line_height":1.8,"paragraph_spacing":8,"audio_speed":2,
|
||||
"audio_timer_minutes":60,"shelf_layout":"grid","shelf_grid_columns":3,
|
||||
"shelf_sort":"author","shelf_show_unread":false,"shelf_show_update_time":false
|
||||
}`); code != http.StatusOK {
|
||||
t.Fatalf("u2 保存失败 status=%d", code)
|
||||
}
|
||||
|
||||
// u1 的值不能被 u2 覆盖
|
||||
back, code := getProfile(t, router, "u1")
|
||||
if code != http.StatusOK || back == nil {
|
||||
t.Fatalf("u1 读回失败 status=%d", code)
|
||||
}
|
||||
if back.ThemeID != "preset3" || back.ShelfLayout != "list" || back.ShelfSort != "name" || !back.Night {
|
||||
t.Fatalf("u1 的偏好被 u2 覆盖: %+v", *back)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReaderProfileSaveTwiceKeepsOneRow(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
router := profileRouterForTest(t, svc)
|
||||
|
||||
first := `{"theme_id":"preset1","page_mode":"page","font_size":20,"line_height":1.8,
|
||||
"paragraph_spacing":8,"audio_speed":1,"shelf_layout":"grid","shelf_sort":"recent"}`
|
||||
if _, code := putProfile(t, router, "u1", first); code != http.StatusOK {
|
||||
t.Fatalf("首次保存 status=%d", code)
|
||||
}
|
||||
second := `{"theme_id":"preset5","page_mode":"scroll","font_size":22,"line_height":2.0,
|
||||
"paragraph_spacing":10,"audio_speed":1.25,"shelf_layout":"compact","shelf_sort":"mixed"}`
|
||||
if _, code := putProfile(t, router, "u1", second); code != http.StatusOK {
|
||||
t.Fatalf("二次保存 status=%d", code)
|
||||
}
|
||||
|
||||
got, _ := getProfile(t, router, "u1")
|
||||
if got == nil || got.ThemeID != "preset5" || got.ShelfSort != "mixed" {
|
||||
t.Fatalf("二次保存未覆盖旧值: %+v", got)
|
||||
}
|
||||
|
||||
// 一个用户只应有一条记录(唯一索引 + upsert)
|
||||
var count int64
|
||||
if err := svc.Repo.DB.Model(&model.ReaderProfile{}).
|
||||
Where("user_id = ?", "u1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("u1 的记录数 = %d,期望 1", count)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
|
||||
// 阅读进度接口的 HTTP 契约测试。
|
||||
//
|
||||
// 听书(音频)的进度是 audio.currentTime,天然带小数;漫画是图片序号、文本是页码,
|
||||
// 都是整数。同一个接口必须同时接住这两种形态,否则音频进度会被 400 丢掉
|
||||
// (前端是 fire-and-forget,失败不报错),表现为「听书退出后从头开始」。
|
||||
|
||||
func progressRouterForTest(cfg *service.Container, userID string) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set(middleware.CtxUserID, userID)
|
||||
c.Next()
|
||||
})
|
||||
registerReaderRoutes(r.Group("/api"), cfg)
|
||||
return r
|
||||
}
|
||||
|
||||
func putProgress(t *testing.T, router *gin.Engine, bookID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/reader/books/"+bookID+"/progress", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsFractionalPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/1",
|
||||
}, "有声书", "作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 37.5 秒:音频进度上报的真实形态
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":2,"pos":37.5,"chapter_title":"第 3 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("音频进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("书架应有 1 本,实际 %d", len(books))
|
||||
}
|
||||
// 小数秒截断成整数秒落库(dur_chapter_pos 是 int)
|
||||
if books[0].DurChapterPos != 37 {
|
||||
t.Fatalf("进度 = %d,期望 37(37.5 截断)", books[0].DurChapterPos)
|
||||
}
|
||||
if books[0].DurChapterIndex != 2 || books[0].DurChapterTitle != "第 3 章" {
|
||||
t.Fatalf("章节/标题未保存: index=%d title=%q", books[0].DurChapterIndex, books[0].DurChapterTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressAcceptsIntegerPos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/2",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 文本/漫画仍是整数页码,不能被破坏
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":7,"pos":12,"chapter_title":"第 8 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("页码进度应被接受,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 12 || books[0].DurChapterIndex != 7 {
|
||||
t.Fatalf("进度 = pos %d / index %d,期望 12 / 7", books[0].DurChapterPos, books[0].DurChapterIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveProgressRejectsNegativePos(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/3",
|
||||
}, "文本", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
// 负数不是合法进度,落库前应被夹到 0,而不是写入负值
|
||||
w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":-5,"chapter_title":"第 1 章"}`)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("负数进度不应 500,实际 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
books, err := svc.Reader.ListBooks(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if books[0].DurChapterPos != 0 {
|
||||
t.Fatalf("进度 = %d,期望夹到 0", books[0].DurChapterPos)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveProgressRoundTripsThroughJSON 防回归:进度写进去要能原样读回来。
|
||||
func TestSaveProgressRoundTripsThroughJSON(t *testing.T) {
|
||||
svc := newReaderHandlerContainer(t)
|
||||
ctx := t.Context()
|
||||
|
||||
book, err := svc.Reader.AddBook(ctx, "u1", reader.SearchOrigin{
|
||||
Origin: "https://example.com",
|
||||
BookURL: "https://example.com/book/4",
|
||||
}, "有声书", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
|
||||
router := progressRouterForTest(svc, "u1")
|
||||
if w := putProgress(t, router, book.ID, `{"chapter_index":0,"pos":123.75,"chapter_title":"第 1 章"}`); w.Code != http.StatusOK {
|
||||
t.Fatalf("保存进度失败 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// 走列表接口(前端书架/阅读器恢复进度读的就是它)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/reader/books", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("书架列表 status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var res struct {
|
||||
Books []struct {
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
} `json:"books"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||
t.Fatalf("解析书架响应失败: %v", err)
|
||||
}
|
||||
if len(res.Books) != 1 || res.Books[0].DurChapterPos != 123 {
|
||||
t.Fatalf("读回的进度 = %+v,期望 123", res.Books)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
"github.com/truewhile/MeBox/internal/service/reader"
|
||||
)
|
||||
@@ -39,6 +40,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
|
||||
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
|
||||
|
||||
// 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
|
||||
g.POST("/comments/open", readerOpenCommentHandler(svc))
|
||||
|
||||
// 搜索(多源聚合)
|
||||
g.POST("/search", readerSearchHandler(svc))
|
||||
|
||||
@@ -57,6 +61,16 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
|
||||
g.POST("/local/audiobooks", middleware.AdminRequired(), readerImportLocalAudioDirHandler(svc))
|
||||
g.DELETE("/books/:id", readerRemoveBookHandler(svc))
|
||||
g.PUT("/books/:id/progress", readerSaveProgressHandler(svc))
|
||||
// 更新目录:重抓书架里全部网络书籍的目录,刷新章节缓存与「最近更新」时间
|
||||
g.POST("/shelf/refresh-toc", readerRefreshBooksTocHandler(svc))
|
||||
|
||||
// 阅读器偏好(每个用户一条):主题 / 排版 / 听书 / 书架展示设置,跨设备同步
|
||||
g.GET("/profile", readerGetProfileHandler(svc))
|
||||
g.PUT("/profile", readerSaveProfileHandler(svc))
|
||||
|
||||
// 书架分组(每个用户一份):组名 → 书籍 ID,仿影视模块的媒体库标签
|
||||
g.GET("/book-groups", readerGetBookGroupsHandler(svc))
|
||||
g.PUT("/book-groups", readerSetBookGroupsHandler(svc))
|
||||
// 换源:把书架里的书切到另一个书源(保留阅读进度,目录缓存按新源重建)
|
||||
g.POST("/books/:id/origin", readerSwitchOriginHandler(svc))
|
||||
g.PUT("/books/:id/audio-config", readerSaveAudioConfigHandler(svc))
|
||||
@@ -238,6 +252,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
|
||||
// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
|
||||
// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
|
||||
func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
BookID string `json:"book_id" binding:"required"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
}
|
||||
|
||||
// readerBrowserPageHandler 承载待办页面本体。//
|
||||
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进 <iframe src>,而 iframe 的请求
|
||||
// 带不上 Authorization 头。签名绑定待办 ID,链接随待办一起过期。
|
||||
@@ -257,12 +295,12 @@ func readerBrowserPageHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// 服务端补上书源 Cookie 与请求头,使「用户后台」这类页面在 iframe 里保持登录态。
|
||||
func readerBrowserAssetHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
target, sourceURL, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
target, _, err := svc.Reader.VerifyBrowserAsset(c.Query("id"), c.Query("u"), c.Query("s"))
|
||||
if err != nil {
|
||||
c.String(http.StatusForbidden, "%s", err.Error())
|
||||
return
|
||||
}
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), sourceURL, target)
|
||||
contentType, status, data, err := svc.Reader.FetchBrowserAsset(c.Request.Context(), c.Query("id"), target)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "资源加载失败: %s", err.Error())
|
||||
return
|
||||
@@ -322,13 +360,17 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
|
||||
SourceIDs []string `json:"source_ids"`
|
||||
// Page 页码(从 1 开始):书源 searchUrl 支持 {{page}} 时,前端滚到底逐页取。
|
||||
Page int `json:"page"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
|
||||
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs, body.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -362,6 +404,11 @@ func readerTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// nil 切片会被编码成 null,前端拿到 null 再取 .length 就是一句
|
||||
// 「Cannot read properties of null」。空目录统一给 []。
|
||||
if chapters == nil {
|
||||
chapters = []reader.TocChapter{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"chapters": chapters})
|
||||
}
|
||||
}
|
||||
@@ -526,6 +573,20 @@ func readerImportLocalAudioDirHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// readerRefreshBooksTocHandler 更新目录:重抓书架里全部网络书籍的目录。
|
||||
// 本地书籍与没有书源信息的书籍跳过;单本失败只计数,不影响其它书。
|
||||
func readerRefreshBooksTocHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
res, err := svc.Reader.RefreshBooksToc(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, res)
|
||||
}
|
||||
}
|
||||
|
||||
func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
@@ -537,11 +598,56 @@ func readerRemoveBookHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
// readerGetProfileHandler 读当前用户的阅读器偏好。
|
||||
// 没保存过时返回 {"profile": null},前端据此用本地值播种。
|
||||
func readerGetProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
settings, err := svc.Reader.GetReaderSettings(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": settings})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSaveProfileHandler 覆盖保存当前用户的阅读器偏好(服务端做范围收敛)。
|
||||
func readerSaveProfileHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body reader.ReaderSettings
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
saved, err := svc.Reader.SaveReaderSettings(c.Request.Context(), userID, body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"profile": saved})
|
||||
}
|
||||
}
|
||||
|
||||
// readerGetBookGroupsHandler 读当前用户的书架分组。
|
||||
// 没有分组时返回空数组(不是 null),前端可以直接遍历。
|
||||
func readerGetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
groups, err := svc.Reader.GetBookGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// readerSetBookGroupsHandler 覆盖保存当前用户的书架分组(整份替换)。
|
||||
func readerSetBookGroupsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
Pos int `json:"pos"`
|
||||
ChapterTitle string `json:"chapter_title"`
|
||||
Groups []model.BookGroupSet `json:"groups"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -549,7 +655,44 @@ func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, body.Pos, body.ChapterTitle); err != nil {
|
||||
groups, err := svc.Reader.SetBookGroups(c.Request.Context(), userID, body.Groups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
}
|
||||
|
||||
// maxReaderChapterPos 进度上限:秒(听书)/ 页码(文本)/ 图片序号(漫画)都远小于它,
|
||||
// 只用来挡住异常大的浮点数转 int 时溢出。约 115 天,足够覆盖任何单章。
|
||||
const maxReaderChapterPos = 10_000_000
|
||||
|
||||
func readerSaveProgressHandler(svc *service.Container) gin.HandlerFunc {
|
||||
var body struct {
|
||||
ChapterIndex int `json:"chapter_index"`
|
||||
// Pos 用 float64 接:听书的进度是 audio.currentTime(秒,天然带小数),
|
||||
// 漫画是图片序号、文本是页码(都是整数)。用 int 接小数会让整个请求
|
||||
// 400,而前端是 fire-and-forget,音频进度就被静默丢掉了。
|
||||
Pos float64 `json:"pos"`
|
||||
ChapterTitle string `json:"chapter_title"`
|
||||
}
|
||||
return func(c *gin.Context) {
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// dur_chapter_pos 落库是 int,截断成秒;负数没有意义,夹到 0。
|
||||
posFloat := body.Pos
|
||||
if !(posFloat > 0) { // 同时挡住 0 与负数(JSON 不会给出 NaN)
|
||||
posFloat = 0
|
||||
}
|
||||
if posFloat > maxReaderChapterPos {
|
||||
posFloat = maxReaderChapterPos
|
||||
}
|
||||
pos := int(posFloat)
|
||||
userID := c.GetString(middleware.CtxUserID)
|
||||
if err := svc.Reader.SaveProgress(c.Request.Context(), userID, c.Param("id"), body.ChapterIndex, pos, body.ChapterTitle); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
+10
-1
@@ -104,10 +104,19 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
|
||||
//
|
||||
// 交给 net/http 管理后:请求仍会带 `Accept-Encoding: gzip`(浏览器常见取值),
|
||||
// 且响应被自动解压;另外也避免服务端挑选我们无法解码的 br。
|
||||
// 注意:Accept 刻意用 `*/*` 而不是浏览器页面导航的
|
||||
// `text/html,application/xhtml+xml,...`。
|
||||
//
|
||||
// 很多书源接口是 DRF(Django REST framework)一类会做内容协商的后端:当 Accept
|
||||
// 首选 text/html 时,它按浏览器语义返回「可浏览的 HTML 页面」(HTTP 200),
|
||||
// 而不是 JSON。引擎再把这份 HTML 交给 JSONPath 解析,结果永远是 0 条——表现为
|
||||
// 「同一个源在手机 App 里能搜到,在 MeBox 里搜不到」(拷贝系列源就是这个坑)。
|
||||
// 手机端阅读 App 走 OkHttp,不显式设置 Accept,等价于 `*/*`;这里对齐该行为。
|
||||
// 需要页面型 Accept 的书源可以在自身 header 里显式声明覆盖。
|
||||
func HTTPHeaderPresets() map[string]string {
|
||||
return map[string]string{
|
||||
"User-Agent": defaultUserAgent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
|
||||
@@ -12,6 +12,18 @@ import (
|
||||
// 背景:预设头里曾显式写 `Accept-Encoding: gzip, deflate, br`,导致 net/http
|
||||
// 不再自动解压(它只在调用方没设置该头时才解压),压缩字节直接进入规则层。
|
||||
|
||||
// TestHeaderPresetsAcceptIsWildcard 预设 Accept 不得首选 text/html。
|
||||
//
|
||||
// 背景:DRF(Django REST framework)等会做内容协商的后端,在 Accept 首选
|
||||
// text/html 时返回「可浏览 HTML 页面」而不是 JSON,书源 JSONPath 因此永远
|
||||
// 解析出 0 条(拷贝轻小说源在 MeBox 搜不到、在阅读 App 能搜到的根因)。
|
||||
func TestHeaderPresetsAcceptIsWildcard(t *testing.T) {
|
||||
got := HTTPHeaderPresets()["Accept"]
|
||||
if got != "*/*" {
|
||||
t.Fatalf("Accept 预设应为 */*(对齐 legado/OkHttp),实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeaderPresetsDoNotSetAcceptEncoding 预设头不得设置 Accept-Encoding。
|
||||
// 一旦设置,net/http 的透明解压就失效,压缩响应会以原始字节交给上层。
|
||||
func TestHeaderPresetsDoNotSetAcceptEncoding(t *testing.T) {
|
||||
|
||||
@@ -66,5 +66,7 @@ func AllModels() []interface{} {
|
||||
&ReaderBook{},
|
||||
&ReaderChapter{},
|
||||
&ReaderReplaceRule{},
|
||||
&ReaderProfile{},
|
||||
&ReaderBookGroups{},
|
||||
}
|
||||
}
|
||||
|
||||
+56
-21
@@ -10,24 +10,27 @@ import (
|
||||
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
|
||||
type ReaderBookSource struct {
|
||||
Base
|
||||
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"`
|
||||
RawJSON string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
|
||||
CustomOrder int `json:"custom_order"`
|
||||
Weight int `json:"weight"`
|
||||
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
|
||||
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
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"`
|
||||
RawJSON string `gorm:"type:text" json:"-"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
|
||||
CustomOrder int `json:"custom_order"`
|
||||
Weight int `json:"weight"`
|
||||
ConcurrentRate string `gorm:"type:varchar(64)" json:"concurrent_rate"`
|
||||
Header string `gorm:"type:text" json:"header"` // 书源级请求头 JSON
|
||||
Comment string `gorm:"type:text" json:"comment"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // source 变量 JSON
|
||||
LastUpdateTime int64 `json:"last_update_time"`
|
||||
LastCheckAt *time.Time `json:"last_check_at"`
|
||||
RespondTime int64 `json:"respond_time"` // 最近一次调试响应耗时(ms)
|
||||
// HasLogin 是否声明了登录能力(loginUrl/loginUi),列表接口按需计算,不落库。
|
||||
HasLogin bool `gorm:"-" json:"has_login"`
|
||||
// NeedsBrowser 是否依赖 WebView/无头浏览器(webView 选项或 webjs 规则)。
|
||||
// 服务端没有浏览器,这类源注定不可用,列表接口按需计算让前端能提前提示。
|
||||
NeedsBrowser bool `gorm:"-" json:"needs_browser"`
|
||||
}
|
||||
|
||||
// ReaderSourceState 书源会话状态:对应 legado 中按书源 key 存储的
|
||||
@@ -58,13 +61,17 @@ type ReaderBook struct {
|
||||
Charset string `gorm:"type:varchar(32)" json:"charset"`
|
||||
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片
|
||||
LatestChapterTitle string `gorm:"type:varchar(512)" json:"latest_chapter_title"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LatestChapterTime 最后一次「目录末尾章节发生变化」的时间(毫秒)。
|
||||
// 对应 legado Book.latestChapterTime:书架「最近更新」排序与「更新时间」展示都读它。
|
||||
// 抓目录时发现末章标题与已存值不同才刷新;本地导入书籍不参与。
|
||||
LatestChapterTime int64 `json:"latest_chapter_time"`
|
||||
TotalChapterNum int `json:"total_chapter_num"`
|
||||
DurChapterIndex int `json:"dur_chapter_index"`
|
||||
DurChapterPos int `json:"dur_chapter_pos"`
|
||||
DurChapterTitle string `gorm:"type:varchar(512)" json:"dur_chapter_title"`
|
||||
DurChapterTime int64 `json:"dur_chapter_time"`
|
||||
Order int `json:"order"`
|
||||
Variable string `gorm:"type:text" json:"variable"`
|
||||
// LocalPath 本地导入书籍的位置:默认是 data/reader/local 下的文件名
|
||||
// (如 "<id>.txt");LocalExternal 为真时是服务器上的绝对路径。
|
||||
// 为空表示来自网络书源。不对外暴露路径,前端用 is_local 判断。
|
||||
@@ -108,3 +115,31 @@ type ReaderReplaceRule struct {
|
||||
TimeoutMillisecond int64 `gorm:"default:3000" json:"timeout_millisecond"`
|
||||
Order int `json:"order"`
|
||||
}
|
||||
|
||||
// ReaderProfile 阅读器偏好(每个用户一条)。
|
||||
//
|
||||
// 主题 / 排版 / 听书 / 书架展示这些设置原先只存在浏览器 localStorage,属于设备级:
|
||||
// 换设备或换浏览器就丢。这里按用户落库作为权威来源,前端 localStorage 退化成首屏缓存。
|
||||
// 首页的「影视 / 阅读」模式(homeMode)是设备级偏好,故意不在这里同步。
|
||||
type ReaderProfile struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
|
||||
ThemeID string `gorm:"type:varchar(32)" json:"theme_id"`
|
||||
Night bool `json:"night"`
|
||||
PageMode string `gorm:"type:varchar(16)" json:"page_mode"` // page 翻页 / scroll 滚动
|
||||
FontSize int `json:"font_size"`
|
||||
LineHeight float64 `json:"line_height"`
|
||||
ParagraphSpacing int `json:"paragraph_spacing"`
|
||||
|
||||
// 听书偏好(对应 legado AudioPlay.playSpeed 与 AppConfig.ttsTimer)
|
||||
AudioSpeed float64 `json:"audio_speed"`
|
||||
AudioTimerMinutes int `json:"audio_timer_minutes"`
|
||||
|
||||
// 书架展示偏好(对应 legado 的书架设置)
|
||||
ShelfLayout string `gorm:"type:varchar(16)" json:"shelf_layout"` // grid / list / compact
|
||||
ShelfGridColumns int `json:"shelf_grid_columns"` // 0 表示自适应
|
||||
ShelfSort string `gorm:"type:varchar(16)" json:"shelf_sort"`
|
||||
ShelfShowUnread bool `json:"shelf_show_unread"`
|
||||
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 书架分组(对应 legado 的 BookGroup)。
|
||||
//
|
||||
// 与影视模块的媒体库标签(LibraryTagSet)同构:组名 → 成员 ID 列表,一个成员只归一个组。
|
||||
// 区别只是归属对象从媒体库换成了书籍,因此前端可以照搬那套标签栏 / 管理弹窗的交互。
|
||||
|
||||
const (
|
||||
// MaxBookGroups 是单个用户可创建的分组数量上限,避免恶意写入过大的 JSON。
|
||||
MaxBookGroups = 50
|
||||
// MaxBookGroupNameLen 是单个分组名的最大字符长度(按 rune 计数)。
|
||||
MaxBookGroupNameLen = 24
|
||||
)
|
||||
|
||||
// BookGroupSet 是一个书架分组:组名 + 组内书籍 ID(顺序即组内展示顺序)。
|
||||
type BookGroupSet struct {
|
||||
Name string `json:"name"`
|
||||
BookIDs []string `json:"book_ids"`
|
||||
}
|
||||
|
||||
// ReaderBookGroups 是某个用户的书架分组集合,每个用户一条记录。
|
||||
//
|
||||
// 分组是「整份替换」语义(前端一次 PUT 提交完整分组列表),所以直接存成一份 JSON;
|
||||
// 与阅读器偏好(ReaderProfile)分表存放,两条写入路径互不干扰:
|
||||
// 先建分组、后调偏好时不会因为整行 Save 把对方的列写成零值。
|
||||
type ReaderBookGroups struct {
|
||||
Base
|
||||
UserID string `gorm:"type:varchar(36);uniqueIndex" json:"user_id"`
|
||||
// Groups 是 []BookGroupSet 的 JSON 文本;空字符串表示没有分组。
|
||||
Groups string `gorm:"type:text" json:"-"`
|
||||
}
|
||||
|
||||
// DecodeBookGroups 解析 Groups 字段,忽略损坏的数据。
|
||||
func (r *ReaderBookGroups) DecodeBookGroups() []BookGroupSet {
|
||||
if r == nil || strings.TrimSpace(r.Groups) == "" {
|
||||
return nil
|
||||
}
|
||||
var groups []BookGroupSet
|
||||
if err := json.Unmarshal([]byte(r.Groups), &groups); err != nil {
|
||||
return nil
|
||||
}
|
||||
return NormalizeBookGroups(groups)
|
||||
}
|
||||
|
||||
// EncodeBookGroups 把分组集合序列化为可写入 Groups 字段的 JSON 文本。
|
||||
// 空集合序列化为空字符串,便于用零值表达「没有分组」。
|
||||
func EncodeBookGroups(groups []BookGroupSet) (string, error) {
|
||||
if len(groups) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := json.Marshal(groups)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// NormalizeBookGroups 清洗分组集合:去掉空名分组、合并重名分组、去掉组内重复的
|
||||
// 书籍 ID,并保持传入顺序。不在这里做「一本书只归一个组」的收敛——那需要知道
|
||||
// 书籍是否存在,属于服务层(SetBookGroups)的职责。
|
||||
func NormalizeBookGroups(groups []BookGroupSet) []BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]BookGroupSet, 0, len(groups))
|
||||
indexByName := make(map[string]int, len(groups))
|
||||
for _, group := range groups {
|
||||
name := TruncateBookGroupName(group.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) >= MaxBookGroups {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
pos, exists := indexByName[key]
|
||||
if !exists {
|
||||
out = append(out, BookGroupSet{Name: name, BookIDs: []string{}})
|
||||
pos = len(out) - 1
|
||||
indexByName[key] = pos
|
||||
}
|
||||
seen := make(map[string]struct{}, len(out[pos].BookIDs))
|
||||
for _, id := range out[pos].BookIDs {
|
||||
seen[id] = struct{}{}
|
||||
}
|
||||
for _, id := range group.BookIDs {
|
||||
trimmed := strings.TrimSpace(id)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
out[pos].BookIDs = append(out[pos].BookIDs, trimmed)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range out {
|
||||
if out[i].BookIDs == nil {
|
||||
out[i].BookIDs = []string{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TruncateBookGroupName 去掉首尾空白并按 rune 截断到长度上限。
|
||||
func TruncateBookGroupName(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(name)
|
||||
if len(runes) > MaxBookGroupNameLen {
|
||||
runes = runes[:MaxBookGroupNameLen]
|
||||
}
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 书架分组的清洗与编解码回归测试(与 library_tag_test.go 同构)。
|
||||
|
||||
func TestNormalizeBookGroupsMergesDuplicatesAndTrims(t *testing.T) {
|
||||
groups := []BookGroupSet{
|
||||
{Name: " 科幻 ", BookIDs: []string{"a", "b"}},
|
||||
{Name: "科幻", BookIDs: []string{"b", "c"}},
|
||||
{Name: " ", BookIDs: []string{"d"}},
|
||||
{Name: "在读", BookIDs: []string{"", "e", "e"}},
|
||||
}
|
||||
got := NormalizeBookGroups(groups)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("NormalizeBookGroups len = %d, want 2 (%#v)", len(got), got)
|
||||
}
|
||||
if got[0].Name != "科幻" || strings.Join(got[0].BookIDs, ",") != "a,b,c" {
|
||||
t.Fatalf("首个分组 = %#v,期望 科幻[a,b,c]", got[0])
|
||||
}
|
||||
if got[1].Name != "在读" || strings.Join(got[1].BookIDs, ",") != "e" {
|
||||
t.Fatalf("第二个分组 = %#v,期望 在读[e]", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsCapsCountAndNameLength(t *testing.T) {
|
||||
long := strings.Repeat("长", MaxBookGroupNameLen+10)
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: long}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
if runes := []rune(got[0].Name); len(runes) != MaxBookGroupNameLen {
|
||||
t.Fatalf("分组名长度 = %d,期望 %d", len(runes), MaxBookGroupNameLen)
|
||||
}
|
||||
|
||||
many := make([]BookGroupSet, 0, MaxBookGroups+5)
|
||||
for i := 0; i < MaxBookGroups+5; i++ {
|
||||
many = append(many, BookGroupSet{Name: string(rune('a' + i))})
|
||||
}
|
||||
if capped := NormalizeBookGroups(many); len(capped) != MaxBookGroups {
|
||||
t.Fatalf("capped len = %d, want %d", len(capped), MaxBookGroups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeBookGroupsKeepsEmptyGroups(t *testing.T) {
|
||||
got := NormalizeBookGroups([]BookGroupSet{{Name: "空组"}})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// 空分组的 BookIDs 应该是空数组而不是 nil,前端直接遍历时不会炸
|
||||
if got[0].BookIDs == nil || len(got[0].BookIDs) != 0 {
|
||||
t.Fatalf("BookIDs = %#v,期望空数组", got[0].BookIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeBookGroupsRoundTrip(t *testing.T) {
|
||||
if encoded, err := EncodeBookGroups(nil); err != nil || encoded != "" {
|
||||
t.Fatalf("EncodeBookGroups(nil) = %q, %v; want \"\", nil", encoded, err)
|
||||
}
|
||||
|
||||
raw, err := EncodeBookGroups([]BookGroupSet{{Name: "科幻", BookIDs: []string{"b-1"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeBookGroups: %v", err)
|
||||
}
|
||||
row := &ReaderBookGroups{Groups: raw}
|
||||
decoded := row.DecodeBookGroups()
|
||||
if len(decoded) != 1 || decoded[0].Name != "科幻" || decoded[0].BookIDs[0] != "b-1" {
|
||||
t.Fatalf("DecodeBookGroups = %#v", decoded)
|
||||
}
|
||||
|
||||
row.Groups = "{not json"
|
||||
if decoded := row.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("损坏数据应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
|
||||
// nil 接收者不应 panic(仓库层查不到记录时会返回 nil)
|
||||
var nilRow *ReaderBookGroups
|
||||
if decoded := nilRow.DecodeBookGroups(); decoded != nil {
|
||||
t.Fatalf("nil 接收者应返回 nil,实际 %#v", decoded)
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,12 @@ 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 != "" {
|
||||
if err := tx.Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
|
||||
// 会话状态是「一源一条」,而 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
|
||||
}
|
||||
}
|
||||
@@ -79,8 +84,11 @@ func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string)
|
||||
|
||||
// SaveSourceState 覆盖保存书源会话状态(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.ReaderSourceState) error {
|
||||
// 用 Unscoped 连软删行一起找:source_url 的唯一索引覆盖软删行,
|
||||
// 只按未删行查会漏掉历史行,随后 Create 必然撞唯一约束,
|
||||
// 结果就是该源的会话状态(含 cookie、登录态)永远保存失败。
|
||||
var existing model.ReaderSourceState
|
||||
err := r.db.WithContext(ctx).First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
err := r.db.WithContext(ctx).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(st).Error
|
||||
}
|
||||
@@ -89,6 +97,11 @@ func (r *ReaderRepository) SaveSourceState(ctx context.Context, st *model.Reader
|
||||
}
|
||||
st.ID = existing.ID
|
||||
st.CreatedAt = existing.CreatedAt
|
||||
if existing.DeletedAt.Valid {
|
||||
// 历史行被软删过:连 deleted_at 一起写回,把它复活
|
||||
st.DeletedAt = gorm.DeletedAt{}
|
||||
return r.db.WithContext(ctx).Unscoped().Save(st).Error
|
||||
}
|
||||
return r.db.WithContext(ctx).Save(st).Error
|
||||
}
|
||||
|
||||
@@ -192,6 +205,63 @@ func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// GetReaderProfile 取用户阅读器偏好;没有记录时返回 (nil, nil),
|
||||
// 由调用方决定是否用前端上送的当前值播种。
|
||||
func (r *ReaderRepository) GetReaderProfile(ctx context.Context, userID string) (*model.ReaderProfile, error) {
|
||||
var out model.ReaderProfile
|
||||
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveReaderProfile 覆盖保存用户阅读器偏好(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveReaderProfile(ctx context.Context, p *model.ReaderProfile) error {
|
||||
var existing model.ReaderProfile
|
||||
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", p.UserID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(p).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.ID = existing.ID
|
||||
p.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(p).Error
|
||||
}
|
||||
|
||||
// GetBookGroups 取用户的书架分组;没有记录时返回 (nil, nil)。
|
||||
func (r *ReaderRepository) GetBookGroups(ctx context.Context, userID string) (*model.ReaderBookGroups, error) {
|
||||
var out model.ReaderBookGroups
|
||||
err := r.db.WithContext(ctx).First(&out, "user_id = ?", userID).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveBookGroups 覆盖保存用户的书架分组(不存在则新建)。
|
||||
func (r *ReaderRepository) SaveBookGroups(ctx context.Context, row *model.ReaderBookGroups) error {
|
||||
var existing model.ReaderBookGroups
|
||||
err := r.db.WithContext(ctx).First(&existing, "user_id = ?", row.UserID).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return r.db.WithContext(ctx).Create(row).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.ID = existing.ID
|
||||
row.CreatedAt = existing.CreatedAt
|
||||
return r.db.WithContext(ctx).Save(row).Error
|
||||
}
|
||||
|
||||
// ListReplaceRules 用户替换规则(按 order 排序)。
|
||||
func (r *ReaderRepository) ListReplaceRules(ctx context.Context, userID string) ([]model.ReaderReplaceRule, error) {
|
||||
var out []model.ReaderReplaceRule
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -98,6 +99,84 @@ func TestDeleteSourceAlsoClearsState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateAfterSourceDeleted 回归:书源删除后,同一 source_url 的状态必须还能存。
|
||||
//
|
||||
// source_url 上的唯一索引覆盖软删行,而 DeleteSource 曾经只做软删:
|
||||
// 之后 SaveSourceState 的 First(默认排除软删行)查不到 → Create 撞唯一约束,
|
||||
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
|
||||
// cookie / 登录态从此永远存不进去(删过或重导入过的书源必现)。
|
||||
func TestSaveSourceStateAfterSourceDeleted(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://d.example.com"
|
||||
|
||||
src := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.DeleteSource(ctx, src.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 重新导入同 URL 的书源,再存一次状态
|
||||
again := &model.ReaderBookSource{Name: "源", SourceURL: url, Enabled: true}
|
||||
if err := repo.CreateSource(ctx, again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"a=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("书源删除/重导入后状态必须还能保存: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"a=2"}` {
|
||||
t.Fatalf("状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveSourceStateRevivesSoftDeletedRow 回归:老数据里已被软删的状态行要能复活。
|
||||
//
|
||||
// 修复前 DeleteSource 是软删,历史库里可能已经躺着软删行;
|
||||
// 保存时必须把它救活,不能去 INSERT 撞唯一约束。
|
||||
func TestSaveSourceStateRevivesSoftDeletedRow(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
ctx := t.Context()
|
||||
const url = "https://e.example.com"
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"old=1"}`,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 造出老版本 DeleteSource 留下的样子:行还在但被软删
|
||||
if err := repo.db.Exec(
|
||||
"update reader_source_states set deleted_at = ? where source_url = ?",
|
||||
time.Now(), url).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
|
||||
SourceURL: url, Cookies: `{"example.com":"new=2"}`,
|
||||
}); err != nil {
|
||||
t.Fatalf("软删过的历史行应被复活,而不是撞唯一约束: %v", err)
|
||||
}
|
||||
st, err := repo.GetSourceState(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st == nil || st.Cookies != `{"example.com":"new=2"}` {
|
||||
t.Fatalf("复活后状态未写入: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportUpdateKeepsSourceState 重新导入书源(更新 RawJSON)不应丢失登录态。
|
||||
func TestImportUpdateKeepsSourceState(t *testing.T) {
|
||||
repo := newReaderTestRepo(t)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -237,6 +238,88 @@ func (r *EmbyRemoteService) ConfiguredRemoteHosts(ctx context.Context) []string
|
||||
return hosts
|
||||
}
|
||||
|
||||
// findAccountByHost 返回某条线路主机名匹配 host 的启用远程 Emby 账号(无则 nil)。
|
||||
func (r *EmbyRemoteService) findAccountByHost(ctx context.Context, host string) *model.StrmAccount {
|
||||
host = normalizeHostKey(host)
|
||||
if host == "" || r == nil || r.repo == nil || r.repo.StrmAccount == nil {
|
||||
return nil
|
||||
}
|
||||
accounts, err := r.ListAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range accounts {
|
||||
lines, _, err := r.LinesOf(&accounts[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range lines {
|
||||
u, err := url.Parse(line.URL)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if normalizeHostKey(u.Hostname()) == host {
|
||||
return &accounts[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeHostKey 去掉端口并小写化,便于主机名字符串比较。
|
||||
func normalizeHostKey(host string) string {
|
||||
host = strings.ToLower(strings.TrimSpace(host))
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return strings.ToLower(strings.TrimSpace(h))
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// RemoteEmbyImageTokenForHost 返回图片代理回源某个远程 Emby 主机所需的当前
|
||||
// api_key。host 不属于任何已配置账号时返回 ok=false。
|
||||
func (r *EmbyRemoteService) RemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, bool) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", false
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", false
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil || strings.TrimSpace(cfg.Token) == "" {
|
||||
return "", false
|
||||
}
|
||||
return cfg.Token, true
|
||||
}
|
||||
|
||||
// RefreshRemoteEmbyImageTokenForHost 强制用用户名/密码重新登录拥有该主机的账号
|
||||
// 并持久化新 token,供图片代理在上游 401 时自愈。仅配置了 api_key、没有登录
|
||||
// 凭据的账号无法自动刷新,返回错误由调用方按原失败处理。
|
||||
func (r *EmbyRemoteService) RefreshRemoteEmbyImageTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if r == nil || r.repo == nil {
|
||||
return "", errors.New("远程 Emby 服务不可用")
|
||||
}
|
||||
acct := r.findAccountByHost(ctx, host)
|
||||
if acct == nil {
|
||||
return "", fmt.Errorf("未找到主机 %s 对应的远程 Emby 账号", host)
|
||||
}
|
||||
cfg, err := r.configOf(acct)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(cfg.Username) == "" || strings.TrimSpace(cfg.Password) == "" {
|
||||
return "", errors.New("远程 Emby 账号未配置用户名/密码,无法自动刷新 api_key")
|
||||
}
|
||||
cfg.Token = "" // 强制走登录流程,忽略已失效的 api_key
|
||||
if err := r.ensureToken(ctx, acct, cfg); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.Token, nil
|
||||
}
|
||||
|
||||
// AccountByID 按 ID 查找远程 Emby 挂载账号(不存在或类型不符返回 nil)。
|
||||
func (r *EmbyRemoteService) AccountByID(ctx context.Context, id string) *model.StrmAccount {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
@@ -699,17 +782,23 @@ func (r *EmbyRemoteService) doGetOnLine(ctx context.Context, acct *model.StrmAcc
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && attempt == 0 {
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不在此时删除
|
||||
// DB 里的 api_key——①外层还会按线路故障转移(其他线路可能
|
||||
// 存有自己的 token);②纯 api_key 账号删除后无法再认证,一次
|
||||
// 线路误报就会把账号“砖化”。重认证成功后 persistToken 会用
|
||||
// 新 token 覆盖 api_key。
|
||||
// 401:只清当前线路的内存 token 并立即重认证;不先删 DB 里的
|
||||
// api_key——纯 api_key 账号删除后无法再认证,一次线路误报就会
|
||||
// 把账号“砖化”。重认证成功后把新 token 写回:既让重试用上正确
|
||||
// 凭据,也避免每个后续请求都重新登录一次。
|
||||
previous := cfg.Token
|
||||
cfg.Token = ""
|
||||
if err := r.ensureTokenOnLine(ctx, acct, cfg); err != nil {
|
||||
return fmt.Errorf("认证重试失败: %w", err)
|
||||
}
|
||||
if q != nil {
|
||||
q.Set("api_key", cfg.Token)
|
||||
}
|
||||
master.Token = cfg.Token
|
||||
master.RemoteUserID = cfg.RemoteUserID
|
||||
if cfg.Token != "" && cfg.Token != previous {
|
||||
_ = r.persistToken(ctx, acct, cfg)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status >= 300 {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostUnknownHost(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "nope.example"); ok || token != "" {
|
||||
t.Fatalf("got (%q,%v), want no token for a host not owned by any account", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteEmbyImageTokenForHostMatchesConfiguredLine(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), "emby.test")
|
||||
if !ok || token != "fake-token" {
|
||||
t.Fatalf("got (%q,%v), want the account token for the configured line host", token, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRequiresCredentials(t *testing.T) {
|
||||
svc, _, _ := newImageTagTestService(t)
|
||||
|
||||
// The account carries only an api_key — there is nothing to re-authenticate with.
|
||||
if _, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), "emby.test"); err == nil {
|
||||
t.Fatal("expected an error when the account has no username/password to re-authenticate with")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRemoteEmbyImageTokenForHostRelogins 覆盖「token 被撤销后自动恢复」:
|
||||
// 用用户名/密码重新登录拿到新 token,并持久化,后续请求不再重复登录。
|
||||
func TestRefreshRemoteEmbyImageTokenForHostRelogins(t *testing.T) {
|
||||
const fresh = "fresh-token"
|
||||
logins := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/emby/Users/AuthenticateByName" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
logins++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"AccessToken":"` + fresh + `","User":{"Id":"remote-user"}}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||
repos := repository.New(db)
|
||||
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-refresh"},
|
||||
Name: "refresh-emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Enabled: true,
|
||||
Config: `{"url":"` + upstream.URL + `","api_key":"stale-token","username":"u","password":"p"}`,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := u.Hostname()
|
||||
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != "stale-token" {
|
||||
t.Fatalf("token before refresh = (%q,%v), want the stale stored token", token, ok)
|
||||
}
|
||||
|
||||
token, err := svc.RefreshRemoteEmbyImageTokenForHost(t.Context(), host)
|
||||
if err != nil {
|
||||
t.Fatalf("refresh: %v", err)
|
||||
}
|
||||
if token != fresh {
|
||||
t.Fatalf("refreshed token = %q, want %q", token, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1", logins)
|
||||
}
|
||||
|
||||
// 新 token 必须落库,否则每个请求都要重新登录一次。
|
||||
if token, ok := svc.RemoteEmbyImageTokenForHost(t.Context(), host); !ok || token != fresh {
|
||||
t.Fatalf("token after refresh = (%q,%v), want the persisted %q", token, ok, fresh)
|
||||
}
|
||||
if logins != 1 {
|
||||
t.Fatalf("logins = %d, want 1 after the refresh was persisted", logins)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -65,6 +66,16 @@ type ImageProxy struct {
|
||||
allowedHostsMu sync.Mutex
|
||||
allowedHostsCache map[string]bool
|
||||
allowedHostsAt time.Time
|
||||
|
||||
// remoteEmbyTokenFn / refreshRemoteEmbyTokenFn let the proxy carry a fresh
|
||||
// credential for configured remote-Emby mounts. Image URLs are minted from
|
||||
// whatever token the account had when the payload was built, so a URL can
|
||||
// outlive its api_key; these hooks re-supply the current one and force a
|
||||
// re-login when the mount rejects it.
|
||||
remoteEmbyTokenFn func(ctx context.Context, host string) (string, bool)
|
||||
refreshRemoteEmbyTokenFn func(ctx context.Context, host string) (string, error)
|
||||
remoteEmbyTokenMu sync.Mutex
|
||||
remoteEmbyTokenCache map[string]remoteEmbyImageToken
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
var errImageProxyRequestSetup = errors.New("image proxy request setup failed")
|
||||
var errImageProxyNonImageContent = errors.New("upstream returned non-image content")
|
||||
|
||||
// errImageProxyUnauthorized 表示上游以 401/403 拒绝了回源凭据。它与普通失败
|
||||
// 分开,是为了让调用方对挂载 Emby 做一次重认证再重试,而不是直接下发占位图。
|
||||
var errImageProxyUnauthorized = errors.New("upstream rejected image credentials")
|
||||
|
||||
// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts):
|
||||
// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。
|
||||
// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。
|
||||
@@ -183,16 +187,27 @@ func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) {
|
||||
}
|
||||
|
||||
func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
result, lastErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if lastErr == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(lastErr, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
// 已挂载的远程 Emby 认 X-Emby-Token 请求头,但轮换前生成的图片 URL 里还留着
|
||||
// 旧 api_key。上游拒绝时重新登录拥有该主机的账号一次再重试,而不是一直下发
|
||||
// 占位图、等人工去改账号配置。
|
||||
if errors.Is(lastErr, errImageProxyUnauthorized) {
|
||||
token, refreshErr := p.refreshRemoteEmbyTokenForHost(ctx, host)
|
||||
if refreshErr != nil {
|
||||
logImageFetchError(p.log, "imageproxy: remote emby re-auth failed", host, "reauth", refreshErr)
|
||||
} else if token != "" {
|
||||
retried, retryErr := p.fetchRemoteImageAcrossClients(ctx, raw, host, cachePath, failPath)
|
||||
if retryErr == nil {
|
||||
return retried, nil
|
||||
}
|
||||
lastErr = retryErr
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if p.canUseExternalImageFallback() && isDoubanImageHost(host) {
|
||||
data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host)
|
||||
@@ -210,6 +225,24 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca
|
||||
return remoteImageFetchResult{}, redactSensitiveError(lastErr)
|
||||
}
|
||||
|
||||
// fetchRemoteImageAcrossClients 按直连/代理顺序依次尝试,返回首个成功结果。
|
||||
// 请求构造失败立即中止(重试无意义);401/403 会继续尝试另一个客户端,并把
|
||||
// errImageProxyUnauthorized 作为最终错误交给调用方去刷新挂载 token。
|
||||
func (p *ImageProxy) fetchRemoteImageAcrossClients(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range p.remoteImageFetchClients(host) {
|
||||
result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath)
|
||||
if err == nil {
|
||||
return result, nil
|
||||
}
|
||||
if errors.Is(err, errImageProxyRequestSetup) {
|
||||
return remoteImageFetchResult{}, err
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return remoteImageFetchResult{}, lastErr
|
||||
}
|
||||
|
||||
// fetchAndCacheRemoteImageShared coalesces concurrent requests for the same
|
||||
// upstream image. A poster can appear in the hero, a shelf and the detail page
|
||||
// at the same time; without this guard every resize variant may fetch the same
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteEmbyImageTokenTTL 是 host → api_key 的缓存时长。海报墙首屏一口气请求
|
||||
// 几十张图,逐个回读数据库不值得;token 轮换时 401 重认证会立即覆盖这份缓存。
|
||||
const remoteEmbyImageTokenTTL = 60 * time.Second
|
||||
|
||||
type remoteEmbyImageToken struct {
|
||||
token string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthProvider 注入「按主机名取当前远程 Emby api_key」的回调。
|
||||
// 图片代理回源已挂载的远程 Emby 时用它替换 URL 里可能已过期的凭据。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthProvider(fn func(ctx context.Context, host string) (string, bool)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// SetRemoteEmbyAuthRefresher 注入「强制重新登录并返回新 api_key」的回调。上游以
|
||||
// 401/403 拒绝旧 token 时调用一次,再重试拉图,从而不需要人工改账号配置。
|
||||
func (p *ImageProxy) SetRemoteEmbyAuthRefresher(fn func(ctx context.Context, host string) (string, error)) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
p.refreshRemoteEmbyTokenFn = fn
|
||||
}
|
||||
|
||||
// remoteEmbyTokenForHost 返回挂载 Emby 的当前 token,短期缓存避免每张图都查库。
|
||||
// 「不是挂载主机」的结果同样缓存,否则普通图床(TMDb/Douban 等)的每张海报都会
|
||||
// 白白查一次账号表。
|
||||
func (p *ImageProxy) remoteEmbyTokenForHost(ctx context.Context, host string) string {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return ""
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if cached, ok := p.remoteEmbyTokenCache[host]; ok && time.Since(cached.at) < remoteEmbyImageTokenTTL {
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
return cached.token
|
||||
}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
|
||||
token, ok := p.remoteEmbyTokenFn(ctx, host)
|
||||
if !ok {
|
||||
token = ""
|
||||
}
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
return token
|
||||
}
|
||||
|
||||
func (p *ImageProxy) cacheRemoteEmbyToken(host, token string) {
|
||||
if p == nil || strings.TrimSpace(host) == "" {
|
||||
return
|
||||
}
|
||||
p.remoteEmbyTokenMu.Lock()
|
||||
if p.remoteEmbyTokenCache == nil {
|
||||
p.remoteEmbyTokenCache = make(map[string]remoteEmbyImageToken, 8)
|
||||
}
|
||||
p.remoteEmbyTokenCache[host] = remoteEmbyImageToken{token: token, at: time.Now()}
|
||||
p.remoteEmbyTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// refreshRemoteEmbyTokenForHost 强制重认证一次并刷新缓存。没有配置刷新器、或
|
||||
// 账号无法自动刷新(如仅填了 api_key)时返回空串,调用方按原错误处理。
|
||||
func (p *ImageProxy) refreshRemoteEmbyTokenForHost(ctx context.Context, host string) (string, error) {
|
||||
if p == nil || p.refreshRemoteEmbyTokenFn == nil || strings.TrimSpace(host) == "" {
|
||||
return "", nil
|
||||
}
|
||||
token, err := p.refreshRemoteEmbyTokenFn(ctx, host)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if token != "" {
|
||||
p.cacheRemoteEmbyToken(host, token)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// applyRemoteEmbyAuth 用账号当前 token 覆盖回源请求上的凭据。已配置的远程 Emby
|
||||
// 以 X-Emby-Token 请求头为准,所以图片 URL 里带的是轮换前的旧 api_key 也不会再
|
||||
// 被采纳;同时把查询参数里的 api_key 一并改写,避免旧值干扰。
|
||||
func (p *ImageProxy) applyRemoteEmbyAuth(ctx context.Context, req *http.Request, host string) {
|
||||
if p == nil || p.remoteEmbyTokenFn == nil || req == nil || req.URL == nil {
|
||||
return
|
||||
}
|
||||
token := p.remoteEmbyTokenForHost(ctx, host)
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", token)
|
||||
q := req.URL.Query()
|
||||
q.Set("api_key", token)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestImageProxyInjectsRemoteEmbyToken 覆盖封面空白问题:图片 URL 里签发时的
|
||||
// api_key 已经失效,但账号当前 token 有效。代理回源必须用当前 token 覆盖旧值,
|
||||
// 否则挂载 Emby 的封面会一直 401、下发占位图。
|
||||
func TestImageProxyInjectsRemoteEmbyToken(t *testing.T) {
|
||||
const current = "current-token"
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != current {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return current, true
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=stale-token"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served with the current token", rec.Body.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyRefreshesRemoteEmbyTokenOn401 覆盖 token 在运行中被撤销的场景:
|
||||
// 账号当前 token 也被上游拒绝时,代理应重新登录一次并重试,且只刷新一次。
|
||||
func TestImageProxyRefreshesRemoteEmbyTokenOn401(t *testing.T) {
|
||||
const (
|
||||
stale = "stale-token"
|
||||
fresh = "fresh-token"
|
||||
)
|
||||
var mu sync.Mutex
|
||||
refreshes := 0
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Emby-Token") != fresh {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return stale, true
|
||||
})
|
||||
proxy.SetRemoteEmbyAuthRefresher(func(context.Context, string) (string, error) {
|
||||
mu.Lock()
|
||||
refreshes++
|
||||
mu.Unlock()
|
||||
return fresh, nil
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/emby/Items/abc/Images/Primary?api_key=" + stale
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), testJPEG) {
|
||||
t.Fatalf("body = %x, want the upstream image served after re-auth", rec.Body.Bytes())
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
got := refreshes
|
||||
mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("refreshes = %d, want exactly 1 re-auth", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageProxyLeavesNonEmbyHostsUntouched 确认新的凭据注入只作用于已配置的
|
||||
// 远程 Emby 主机:普通图床(这里用 provider 返回 ok=false 模拟)不受影响。
|
||||
func TestImageProxyDoesNotInjectForUnknownHost(t *testing.T) {
|
||||
var gotHeader string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotHeader = r.Header.Get("X-Emby-Token")
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
_, _ = w.Write(testJPEG)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
u, err := url.Parse(upstream.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := newUpstreamImageProxy(t, u.Host)
|
||||
proxy.SetRemoteEmbyAuthProvider(func(context.Context, string) (string, bool) {
|
||||
return "", false
|
||||
})
|
||||
|
||||
raw := upstream.URL + "/img/cover.jpg"
|
||||
rec := httptest.NewRecorder()
|
||||
if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotHeader != "" {
|
||||
t.Fatalf("X-Emby-Token = %q, want no credential header for a non-Emby host", gotHeader)
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
return remoteImageFetchResult{}, errImageProxyRequestSetup
|
||||
}
|
||||
applyRemoteImageHeaders(req, host, raw)
|
||||
// 已挂载的远程 Emby:用账号当前 token 覆盖旧凭据,返回头形式对端优先采纳。
|
||||
p.applyRemoteEmbyAuth(ctx, req, host)
|
||||
|
||||
resp, err := candidate.client.Do(req)
|
||||
if err != nil {
|
||||
@@ -80,6 +82,9 @@ func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string,
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status))
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return remoteImageFetchResult{}, errImageProxyUnauthorized
|
||||
}
|
||||
return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status)
|
||||
}
|
||||
if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil {
|
||||
|
||||
@@ -286,8 +286,12 @@ func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, sourc
|
||||
if ck := state.CookieForRequest(source); ck != "" {
|
||||
headers["Cookie"] = ck
|
||||
}
|
||||
// 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法,
|
||||
// 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。
|
||||
if headers["Referer"] == "" {
|
||||
headers["Referer"] = strings.TrimSuffix(book.Origin, "/") + "/"
|
||||
if referer := sourceReferer(book.Origin); referer != "" {
|
||||
headers["Referer"] = referer
|
||||
}
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:书籍类型兜底的回归测试。
|
||||
//
|
||||
// 背景:书籍详情页的「加入书架/开始阅读」曾把 origin_type 写死成 0(文本),
|
||||
// 于是听书源的音频书以文本类型落库。正文类型按「规则 JS 声明 > 书架类型 >
|
||||
// 书源类型」取值,书架里的 0 压过书源的 1,播放直链就被当成正文排出来,
|
||||
// 整个阅读页是一屏 URL(用户截图里的现象)。
|
||||
//
|
||||
// 兜底规则:书架类型还停在默认的「文本」时用书源类型;规则 JS 显式声明的
|
||||
// 类型仍然最优先,避免改坏「文本型聚合源提供听书内容」那类源。
|
||||
|
||||
// audioTestSourceJSON 一个最小听书源:正文规则给 JSON 里的播放直链。
|
||||
func audioTestSourceJSON(t *testing.T, base string) string {
|
||||
t.Helper()
|
||||
return `{
|
||||
"bookSourceName": "听书测试源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + base + `",
|
||||
"ruleContent": {"content": "$.data.url"}
|
||||
}`
|
||||
}
|
||||
|
||||
// TestAudioBookTypeFallbackBySource 书架类型是默认的文本时按书源类型(音频)渲染,
|
||||
// 并把纠正后的类型写回书架记录。
|
||||
func TestAudioBookTypeFallbackBySource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, audioTestSourceJSON(t, srv.URL))
|
||||
|
||||
// 复刻详情页写死的 origin_type=0
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID,
|
||||
Origin: srv.URL,
|
||||
OriginName: "听书测试源",
|
||||
OriginType: 0,
|
||||
BookURL: srv.URL + "/book/1",
|
||||
}, "测试听书", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "audio" {
|
||||
t.Fatalf("正文类型 = %q(期望 audio:书架类型是默认文本时应当按书源类型兜底),正文 = %.120q",
|
||||
out.Type, out.Content)
|
||||
}
|
||||
if len(out.Tracks) != 1 {
|
||||
t.Fatalf("音轨数 = %d,期望 1", len(out.Tracks))
|
||||
}
|
||||
// 音轨要落到签名代理上(浏览器播放带不上书源的防盗链头)
|
||||
if !strings.HasPrefix(out.Tracks[0], "/api/reader/media?") {
|
||||
t.Fatalf("音轨 = %q,期望签名代理地址", out.Tracks[0])
|
||||
}
|
||||
raw, err := svc.VerifyProxyURL(book.ID, trackQueryValue(t, out.Tracks[0], "u"), trackQueryValue(t, out.Tracks[0], "s"))
|
||||
if err != nil {
|
||||
t.Fatalf("代理地址校验失败: %v", err)
|
||||
}
|
||||
if raw != "https://cdn.example.com/a.m4a" {
|
||||
t.Fatalf("代理还原地址 = %q", raw)
|
||||
}
|
||||
|
||||
// 纠正后的类型要写回书架:否则每读一章都要再兜底一次,注入规则 JS 的
|
||||
// book.type 也一直是错的(书源 JS 会读它分支)。
|
||||
reloaded, err := svc.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("重读书籍失败: %v", err)
|
||||
}
|
||||
if reloaded.Type != 1 {
|
||||
t.Fatalf("书架记录类型 = %d,期望 1(音频)", reloaded.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeclaredTextTypeWinsOverAudioSource 规则 JS 显式声明类型时不套用书源类型兜底:
|
||||
// 文本型聚合源(bookSourceType=0,但听书内容靠 JS 声明)反过来也不会被覆盖成文本。
|
||||
func TestDeclaredTextTypeWinsOverAudioSource(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":{"url":"https://cdn.example.com/a.m4a"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
// 音频书源,但正文规则用 JS 把 book.type 声明成文本(legado 的 BookType 位掩码 8)
|
||||
src := `{
|
||||
"bookSourceName": "声明文本的音频源",
|
||||
"bookSourceType": 1,
|
||||
"bookSourceUrl": "` + srv.URL + `",
|
||||
"ruleContent": {"content": "@js:book.type = 8; result"}
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, src)
|
||||
|
||||
book, err := svc.AddBook(ctx, readerTestUserID, SearchOrigin{
|
||||
SourceID: sourceID, Origin: srv.URL, OriginName: "声明文本的音频源",
|
||||
OriginType: 0, BookURL: srv.URL + "/book/1",
|
||||
}, "声明文本", "某作者", "")
|
||||
if err != nil {
|
||||
t.Fatalf("加入书架失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/content"},
|
||||
}); err != nil {
|
||||
t.Fatalf("保存目录失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, readerTestUserID, book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("正文类型 = %q,期望 text(规则 JS 声明的类型优先于书源类型)", out.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// trackQueryValue 取出代理地址里的查询参数值(测试用)。
|
||||
func trackQueryValue(t *testing.T, raw, key string) string {
|
||||
t.Helper()
|
||||
idx := strings.Index(raw, key+"=")
|
||||
if idx < 0 {
|
||||
t.Fatalf("地址 %q 里没有参数 %s", raw, key)
|
||||
}
|
||||
rest := raw[idx+len(key)+1:]
|
||||
if end := strings.Index(rest, "&"); end >= 0 {
|
||||
rest = rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:段落评论接口(/para_review)的分页适配。
|
||||
//
|
||||
// 上游聚合站自带的评论页按 page=N 翻页(滑到底请求 page=2、page=3…),但它的列表
|
||||
// 接口早就改成了游标分页:响应里给 next_cursor,请求时要带 cursor=。接口不认识
|
||||
// page,于是每次「加载更多」都原样返回第一页——一条新评论都加不进去,has_more 又
|
||||
// 始终是 true,评论页就一遍遍触发加载更多、反复闪骨架屏。用户看到的就是「段评往
|
||||
// 下滑一闪一闪」,其实评论一条没多。
|
||||
//
|
||||
// 评论页是上游 HTML,改不了,于是在代理层做最小适配:
|
||||
// - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
|
||||
// - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
|
||||
// 免得页面在「加载更多 → 没有新内容」之间空转。
|
||||
//
|
||||
// 只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求;对不上就原样透传,
|
||||
// 不影响别的书源。
|
||||
|
||||
// commentPageState 一个评论列表(同一段落 + 同一排序)的翻页状态。
|
||||
type commentPageState struct {
|
||||
// cursor 上一页响应给的游标:下一页请求用它代替 page。
|
||||
cursor string
|
||||
// seen 已经下发给页面的 comment_id:整页重复时据此收尾。
|
||||
seen map[string]struct{}
|
||||
}
|
||||
|
||||
// commentPager 承载页面里评论接口的翻页适配器(每个承载页面一份)。
|
||||
type commentPager struct {
|
||||
mu sync.Mutex
|
||||
states map[string]*commentPageState
|
||||
}
|
||||
|
||||
func newCommentPager() *commentPager {
|
||||
return &commentPager{states: map[string]*commentPageState{}}
|
||||
}
|
||||
|
||||
// rewriteRequest 按需把 page=N 改写成 cursor=<上一页游标>。
|
||||
//
|
||||
// 返回改写后的地址与状态键(空串表示这个请求不需要适配,调用方也不必观察响应)。
|
||||
func (p *commentPager) rewriteRequest(target string) (string, string) {
|
||||
if p == nil {
|
||||
return target, ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil || !isCommentListPath(parsed.Path) {
|
||||
return target, ""
|
||||
}
|
||||
query := parsed.Query()
|
||||
pageRaw := strings.TrimSpace(query.Get("page"))
|
||||
if pageRaw == "" || strings.TrimSpace(query.Get("cursor")) != "" {
|
||||
return target, ""
|
||||
}
|
||||
page, err := strconv.Atoi(pageRaw)
|
||||
if err != nil || page < 1 {
|
||||
return target, ""
|
||||
}
|
||||
key := commentPageKey(parsed.Path, query)
|
||||
if page == 1 {
|
||||
// 重新加载 / 换了排序:游标与去重表都从头来。
|
||||
p.mu.Lock()
|
||||
delete(p.states, key)
|
||||
p.mu.Unlock()
|
||||
return target, key
|
||||
}
|
||||
p.mu.Lock()
|
||||
cursor := ""
|
||||
if st := p.states[key]; st != nil {
|
||||
cursor = st.cursor
|
||||
}
|
||||
p.mu.Unlock()
|
||||
if cursor == "" {
|
||||
// 不是从第一页翻过来的(没有游标可用):原样透传,仍然观察响应,
|
||||
// 靠下面的「整页重复」判断让页面停下来。
|
||||
return target, key
|
||||
}
|
||||
query.Set("cursor", cursor)
|
||||
query.Del("page")
|
||||
parsed.RawQuery = query.Encode()
|
||||
return parsed.String(), key
|
||||
}
|
||||
|
||||
// observeResponse 观察评论列表响应:记下游标,并在整页重复时把 has_more 改成 false。
|
||||
func (p *commentPager) observeResponse(key, body string) string {
|
||||
if p == nil || key == "" {
|
||||
return body
|
||||
}
|
||||
trimmed := strings.TrimSpace(body)
|
||||
if !strings.HasPrefix(trimmed, "{") {
|
||||
return body
|
||||
}
|
||||
var env commentListEnvelope
|
||||
if err := json.Unmarshal([]byte(trimmed), &env); err != nil {
|
||||
return body
|
||||
}
|
||||
if env.Data.Comments == nil && env.Data.HasMore == nil {
|
||||
return body
|
||||
}
|
||||
ids := make([]string, 0, len(env.Data.Comments))
|
||||
for _, c := range env.Data.Comments {
|
||||
if id := strings.TrimSpace(c.CommentID); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.states == nil {
|
||||
p.states = map[string]*commentPageState{}
|
||||
}
|
||||
st := p.states[key]
|
||||
if st == nil {
|
||||
st = &commentPageState{seen: map[string]struct{}{}}
|
||||
p.states[key] = st
|
||||
}
|
||||
repeated := len(ids) > 0
|
||||
for _, id := range ids {
|
||||
if _, ok := st.seen[id]; !ok {
|
||||
repeated = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if repeated && env.Data.HasMore != nil && *env.Data.HasMore {
|
||||
// 整页都是老评论:接口不认 page,再翻下去也只会拿到同一页。
|
||||
// 改掉 has_more,让页面显示「没有更多了」,别再反复拉取闪骨架屏。
|
||||
if replaced, ok := setFirstJSONBoolFalse(trimmed, "has_more"); ok {
|
||||
return replaced
|
||||
}
|
||||
return body
|
||||
}
|
||||
for _, id := range ids {
|
||||
st.seen[id] = struct{}{}
|
||||
}
|
||||
if env.Data.HasMore != nil && !*env.Data.HasMore {
|
||||
// 已经是最后一页:游标链到头,清掉它,页面万一再要一页也别拿旧游标
|
||||
// 去请求(那种请求会落在下面的「整页重复」判断上收尾)。
|
||||
st.cursor = ""
|
||||
return body
|
||||
}
|
||||
// 游标只在拿到新内容时前进:请求失败/重复时页面会重试同一页,
|
||||
// 游标也跟着重试同一个。
|
||||
if cursor := rawJSONScalar(env.Data.Cursor); cursor != "" {
|
||||
st.cursor = cursor
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// commentListEnvelope 评论列表响应里我们关心的字段。
|
||||
type commentListEnvelope struct {
|
||||
Data struct {
|
||||
Comments []struct {
|
||||
CommentID string `json:"comment_id"`
|
||||
} `json:"comments"`
|
||||
HasMore *bool `json:"has_more"`
|
||||
Cursor json.RawMessage `json:"next_cursor"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// isCommentListPath 判断是否是段落评论列表接口(/para_review)。
|
||||
func isCommentListPath(path string) bool {
|
||||
p := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(path)), "/")
|
||||
return strings.HasSuffix(p, "/para_review")
|
||||
}
|
||||
|
||||
// commentPageKey 生成翻页状态键:接口路径 + 除 page/cursor 之外的查询参数。
|
||||
// 同一段落的不同排序(sort_by)各自一套状态,互不干扰。
|
||||
func commentPageKey(path string, query url.Values) string {
|
||||
rest := make(url.Values, len(query))
|
||||
for k, v := range query {
|
||||
if k == "page" || k == "cursor" {
|
||||
continue
|
||||
}
|
||||
rest[k] = v
|
||||
}
|
||||
keys := make([]string, 0, len(rest))
|
||||
for k := range rest {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var b strings.Builder
|
||||
b.WriteString(path)
|
||||
for _, k := range keys {
|
||||
for _, v := range rest[k] {
|
||||
b.WriteString("|" + k + "=" + v)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// rawJSONScalar 取一个 JSON 标量的字面量(游标是毫秒时间戳,可能被上游写成数字)。
|
||||
func rawJSONScalar(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(s, `"`) {
|
||||
var out string
|
||||
if json.Unmarshal(raw, &out) == nil {
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// setFirstJSONBoolFalse 把 JSON 里第一个 `"key":true` 改成 `"key":false`。
|
||||
//
|
||||
// 只做这一处等长替换,不重新序列化整份响应:评论里的 user_id、时间戳动辄十几位,
|
||||
// 走一遍 float64 会变成科学计数法,页面再解析就废了。
|
||||
func setFirstJSONBoolFalse(body, key string) (string, bool) {
|
||||
token := `"` + key + `"`
|
||||
idx := strings.Index(body, token)
|
||||
if idx < 0 {
|
||||
return body, false
|
||||
}
|
||||
i := idx + len(token)
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if i >= len(body) || body[i] != ':' {
|
||||
return body, false
|
||||
}
|
||||
i++
|
||||
for i < len(body) && (body[i] == ' ' || body[i] == '\t' || body[i] == '\n' || body[i] == '\r') {
|
||||
i++
|
||||
}
|
||||
if !strings.HasPrefix(body[i:], "true") {
|
||||
return body, false
|
||||
}
|
||||
return body[:i] + "false" + body[i+len("true"):], true
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:段落评论翻页适配(browser_comment_page.go)的测试。
|
||||
//
|
||||
// 覆盖两件事:
|
||||
// 1. page=N(N>1)被换成上一页响应给的 cursor;
|
||||
// 2. 整页重复(接口不认 page 时的表现)时把 has_more 改成 false,页面才会收尾。
|
||||
|
||||
const commentParaBase = "https://cmt.example.com/para_review?item_id=X¶=1&source=QQ"
|
||||
|
||||
func TestCommentPagerRewritesPageToCursor(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
|
||||
// 第一页:原样请求,响应里的游标被记下
|
||||
target, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
if key == "" {
|
||||
t.Fatal("段落评论接口应被识别")
|
||||
}
|
||||
if target != commentParaBase+"&page=1" {
|
||||
t.Fatalf("第一页不该改写: %q", target)
|
||||
}
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
|
||||
// 第二页:改成带 cursor 的请求,page 不再出现
|
||||
target, key2 := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if key2 != key {
|
||||
t.Fatalf("同一段落的状态键应一致: %q vs %q", key2, key)
|
||||
}
|
||||
if !strings.Contains(target, "cursor=1500000000000") || strings.Contains(target, "page=") {
|
||||
t.Fatalf("第二页应换成 cursor 请求: %q", target)
|
||||
}
|
||||
|
||||
// 拿到新评论:游标前进到下一页
|
||||
p.observeResponse(key, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=3")
|
||||
if !strings.Contains(target, "cursor=1400000000000") {
|
||||
t.Fatalf("第三页应使用新的游标: %q", target)
|
||||
}
|
||||
|
||||
// 重新加载(page=1)会把整轮状态清掉:没有游标就原样透传
|
||||
p.rewriteRequest(commentParaBase + "&page=1")
|
||||
target, _ = p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if strings.Contains(target, "cursor=") {
|
||||
t.Fatalf("重置后不该还带着旧游标: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerStopsOnRepeatedPage(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
_, key := p.rewriteRequest(commentParaBase + "&page=1")
|
||||
first := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
if got := p.observeResponse(key, first); got != first {
|
||||
t.Fatalf("第一页不该被改写: %q", got)
|
||||
}
|
||||
|
||||
// 同一页又被返回一次(接口忽略 page):改 has_more,让页面显示「没有更多了」
|
||||
repeat := `{"code":0,"data":{"comments":[{"comment_id":"c1","like_count":562,"user":{"user_id":601948729051219}}],"has_more":true,"next_cursor":"1500000000000"}}`
|
||||
got := p.observeResponse(key, repeat)
|
||||
if !strings.Contains(got, `"has_more":false`) {
|
||||
t.Fatalf("重复页应把 has_more 改成 false: %q", got)
|
||||
}
|
||||
// 只动 has_more 一处,其它字段(尤其是大整数)保持原样
|
||||
if strings.Replace(got, `"has_more":false`, `"has_more":true`, 1) != first {
|
||||
t.Fatalf("除 has_more 外不应改动响应: %q", got)
|
||||
}
|
||||
// 重复页不再推进游标
|
||||
target, _ := p.rewriteRequest(commentParaBase + "&page=2")
|
||||
if !strings.Contains(target, "cursor=1500000000000") {
|
||||
t.Fatalf("游标不该被重复页顶掉: %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentPagerIgnoresOtherRequests(t *testing.T) {
|
||||
p := newCommentPager()
|
||||
cases := []string{
|
||||
"https://cmt.example.com/book/detail?page=2", // 不是评论接口
|
||||
"https://cmt.example.com/para_review?item_id=X", // 没有 page
|
||||
"https://cmt.example.com/para_review?item_id=X&cursor=1", // 已经在用游标
|
||||
"https://cmt.example.com/para_review?item_id=X&page=0", // page 非法
|
||||
"https://cmt.example.com/para_review?item_id=X&sort_by=1&", // 没有 page
|
||||
}
|
||||
for _, target := range cases {
|
||||
got, key := p.rewriteRequest(target)
|
||||
if got != target || key != "" {
|
||||
t.Fatalf("不该改写的请求被处理了: %q -> %q (key=%q)", target, got, key)
|
||||
}
|
||||
}
|
||||
// 非 JSON 响应原样返回
|
||||
if got := p.observeResponse("k", "<html></html>"); got != "<html></html>" {
|
||||
t.Fatalf("非 JSON 响应被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserXHRCommentPagerFollowsCursor 端到端:页面按 page 翻页,
|
||||
// 代理层换成 cursor,接口给出新评论,最后一页重复时收尾。
|
||||
func TestBrowserXHRCommentPagerFollowsCursor(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
var mu sync.Mutex
|
||||
seenCursors := []string{}
|
||||
seenPages := []string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/page":
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, `<html><body>comment page</body></html>`)
|
||||
case "/para_review":
|
||||
q := r.URL.Query()
|
||||
mu.Lock()
|
||||
seenCursors = append(seenCursors, q.Get("cursor"))
|
||||
seenPages = append(seenPages, q.Get("page"))
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
// 上游只认 cursor:不认识 page,靠 next_cursor 一页页往前。
|
||||
switch q.Get("cursor") {
|
||||
case "":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c1"}],"has_more":true,"next_cursor":"1500000000000"}}`)
|
||||
case "1500000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c2"}],"has_more":true,"next_cursor":"1400000000000"}}`)
|
||||
case "1400000000000":
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":false,"next_cursor":""}}`)
|
||||
default:
|
||||
// 兜底分支:上游把同一页又给了一遍(页面的 page 循环就会走到这里)
|
||||
_, _ = io.WriteString(w, `{"code":0,"data":{"comments":[{"comment_id":"c3"}],"has_more":true,"next_cursor":"1300000000000"}}`)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: srv.URL + "/page", Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
call := func(page string) string {
|
||||
t.Helper()
|
||||
res, err := svc.ProxyBrowserXHR(t.Context(), entry.id, http.MethodGet,
|
||||
srv.URL+"/para_review?item_id=X¶=1&source=QQ&page="+page, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
return res.Body
|
||||
}
|
||||
|
||||
if body := call("1"); !strings.Contains(body, `"has_more":true`) || !strings.Contains(body, "c1") {
|
||||
t.Fatalf("第一页响应异常: %q", body)
|
||||
}
|
||||
if body := call("2"); !strings.Contains(body, "c2") {
|
||||
t.Fatalf("第二页应拿到下一页评论: %q", body)
|
||||
}
|
||||
if body := call("3"); !strings.Contains(body, "c3") || !strings.Contains(body, `"has_more":false`) {
|
||||
t.Fatalf("第三页应是真正的最后一页: %q", body)
|
||||
}
|
||||
// 页面不知道已经到底,还可能再要一页:上游又把最后一页给了一遍,
|
||||
// 适配层要把 has_more 改成 false,页面才会显示「没有更多了」并停下。
|
||||
last := call("4")
|
||||
if !strings.Contains(last, `"has_more":false`) {
|
||||
t.Fatalf("整页重复时应收尾(has_more=false): %q", last)
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if strings.Join(seenPages, ",") != "1,,,4" {
|
||||
t.Fatalf("page 参数只应出现在没有游标可用的请求上: %v", seenPages)
|
||||
}
|
||||
if strings.Join(seenCursors, ",") != ",1500000000000,1400000000000," {
|
||||
t.Fatalf("游标传递不符预期: %v", seenCursors)
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal([]byte(last), &env); err != nil {
|
||||
t.Fatalf("收尾后的响应不是合法 JSON: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,9 @@ type BrowserPage struct {
|
||||
Title string `json:"title"`
|
||||
// Mode wait = 需回传(点 √ 后回传 DOM);open = 仅展示。
|
||||
Mode string `json:"mode"`
|
||||
// Seq 登记序号(单调递增)。待办表可能同时存多个页面,前端按它取最新的
|
||||
// 那个展示——不能依赖返回顺序,map 遍历是随机的。
|
||||
Seq int64 `json:"seq"`
|
||||
// PageURL 同源承载地址(iframe src),带 HMAC 签名。
|
||||
PageURL string `json:"page_url"`
|
||||
// Refetch 见 rule.BrowserTask.Refetch。
|
||||
@@ -83,17 +86,26 @@ type BrowserPageSnapshot struct {
|
||||
}
|
||||
|
||||
// pendingBrowser 一个待用户完成的页面。
|
||||
// seq 为登记序号(越大越新,见 ReaderService.browserSeq),供前端挑选最新页面。
|
||||
type pendingBrowser struct {
|
||||
id string
|
||||
seq int64
|
||||
sourceID string
|
||||
sourceURL string
|
||||
userID string
|
||||
request rule.BrowserTask
|
||||
// cookies 是发起这次页面的会话 Cookie 累积区:页面在面板里发出的
|
||||
// 请求(接口代理 / 资源代理)会把 Set-Cookie 写回它,用户点「完成」后
|
||||
// 书源会话就能读到登录凭证。
|
||||
cookies browserCookieTarget
|
||||
// html 已就绪的页面源码(data: 直接解码;http 抓取后资源地址已改写)。
|
||||
html string
|
||||
finalURL string
|
||||
mode string
|
||||
expires time.Time
|
||||
// comments 段落评论接口的翻页适配(上游页面按 page 翻页,接口只认 cursor,
|
||||
// 见 browser_comment_page.go)。
|
||||
comments *commentPager
|
||||
|
||||
done chan struct{}
|
||||
mu sync.Mutex
|
||||
@@ -108,21 +120,56 @@ type browserHost struct {
|
||||
sourceURL string
|
||||
sourceID string
|
||||
userID string
|
||||
// state / capture 是这次会话的 Cookie 累积区与 enabledCookieJar 开关。
|
||||
// 面板承载的页面自己发起的请求(ProxyBrowserXHR / 资源代理)会把
|
||||
// 响应 Set-Cookie 写进 state,从而让「页面里登录成功 = 书源会话拿到凭证」。
|
||||
state *sourceState
|
||||
capture bool
|
||||
}
|
||||
|
||||
func (h *browserHost) AwaitBrowser(ctx context.Context, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.awaitBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
func (h *browserHost) OpenBrowser(ctx context.Context, req rule.BrowserTask) error {
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, req)
|
||||
return h.svc.openBrowser(ctx, h.sourceURL, h.sourceID, h.userID, h.cookieTarget(), req)
|
||||
}
|
||||
|
||||
// cookieTarget 打包会话状态与开关;capture 为假时返回携带 nil state 的目标。
|
||||
func (h *browserHost) cookieTarget() browserCookieTarget {
|
||||
if !h.capture {
|
||||
return browserCookieTarget{}
|
||||
}
|
||||
return browserCookieTarget{state: h.state}
|
||||
}
|
||||
|
||||
// browserCookieTarget 面板链路要回写 Cookie 的目标会话。
|
||||
type browserCookieTarget struct {
|
||||
state *sourceState
|
||||
}
|
||||
|
||||
// captureFrom 把这次请求(含重定向各跳)下发的 Set-Cookie 写回会话。
|
||||
// ctx 上挂了 sink 时以它为准(逐跳收集,见 cookies.go),最后再补最终响应。
|
||||
// 归属域一律 Cookie 的 Domain 优先、否则该跳地址。
|
||||
func (t browserCookieTarget) captureFrom(ctx context.Context, resp *http.Response) {
|
||||
if t.state == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
sink := cookieSinkFrom(ctx)
|
||||
if sink == nil {
|
||||
sink = &cookieSink{}
|
||||
}
|
||||
if resp.Request != nil {
|
||||
sink.add(resp.Request.URL, resp)
|
||||
}
|
||||
sink.apply(t.state)
|
||||
}
|
||||
|
||||
// ─── 服务层入口 ────────────────────────────────────────────────────────────
|
||||
|
||||
// awaitBrowser 登记待办并阻塞等待用户回传页面内容。
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeWait)
|
||||
func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) (rule.BrowserResult, error) {
|
||||
entry, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeWait)
|
||||
if err != nil {
|
||||
return rule.BrowserResult{}, err
|
||||
}
|
||||
@@ -146,16 +193,16 @@ func (s *ReaderService) awaitBrowser(ctx context.Context, sourceURL, sourceID, u
|
||||
}
|
||||
|
||||
// openBrowser 登记待办但不等待(页面展示给用户即可)。
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, req, browserModeOpen)
|
||||
func (s *ReaderService) openBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask) error {
|
||||
_, err := s.registerBrowser(ctx, sourceURL, sourceID, userID, cookies, req, browserModeOpen)
|
||||
return err
|
||||
}
|
||||
|
||||
// registerBrowser 准备页面内容并登记待办。
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID, userID string, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
|
||||
// 先分配 ID:页面里的资源代理地址需要用它签名。
|
||||
id := newBrowserID()
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, req)
|
||||
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,11 +215,13 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
sourceURL: sourceURL,
|
||||
userID: userID,
|
||||
request: req,
|
||||
cookies: cookies,
|
||||
html: html,
|
||||
finalURL: finalURL,
|
||||
mode: mode,
|
||||
expires: time.Now().Add(browserPageTTL),
|
||||
done: make(chan struct{}),
|
||||
comments: newCommentPager(),
|
||||
}
|
||||
|
||||
s.browserMu.Lock()
|
||||
@@ -180,6 +229,8 @@ func (s *ReaderService) registerBrowser(ctx context.Context, sourceURL, sourceID
|
||||
s.browserPending = map[string]*pendingBrowser{}
|
||||
}
|
||||
s.pruneBrowsersLocked()
|
||||
s.browserSeq++
|
||||
entry.seq = s.browserSeq
|
||||
s.browserPending[id] = entry
|
||||
s.browserMu.Unlock()
|
||||
|
||||
@@ -312,7 +363,7 @@ func jsStringEscape(s string) string {
|
||||
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
|
||||
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
|
||||
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, req rule.BrowserTask) (string, string, error) {
|
||||
func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id string, cookies browserCookieTarget, req rule.BrowserTask) (string, string, error) {
|
||||
// 1) 书源自带 HTML(显式 html 参数或 data: URL)
|
||||
if html := strings.TrimSpace(req.HTML); html != "" {
|
||||
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
|
||||
@@ -324,10 +375,16 @@ func (s *ReaderService) prepareBrowserPage(ctx context.Context, sourceURL, id st
|
||||
if !strings.HasPrefix(req.URL, "http://") && !strings.HasPrefix(req.URL, "https://") {
|
||||
return "", "", fmt.Errorf("无法承载该地址: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, req.URL)
|
||||
body, finalURL, contentType, err := s.fetchBrowserPage(ctx, sourceURL, cookies, req.URL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("打开页面失败: %w", err)
|
||||
}
|
||||
// 空响应没有可展示的内容:以前照样包一层空 <pre> 交给 iframe,面板就是一片白,
|
||||
// 用户看不出是地址有问题还是自己没登录。这里直接报错(如段评地址参数被改写、
|
||||
// 上游按缺参数返回空 body 的情形)。
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", "", fmt.Errorf("目标地址没有返回任何内容: %s", truncateForLog(req.URL, 120))
|
||||
}
|
||||
cookie := s.browserCookieHeader(ctx, sourceURL, finalURL)
|
||||
if !strings.Contains(strings.ToLower(contentType), "html") {
|
||||
// 非 HTML(如 JSON 接口):包一层 <pre>,至少让用户看到内容
|
||||
@@ -348,8 +405,8 @@ func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, targ
|
||||
}
|
||||
|
||||
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, string, string, error) {
|
||||
contentType, _, data, finalURL, err := s.requestBrowserResource(ctx, sourceURL, cookies, target)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
@@ -357,16 +414,25 @@ func (s *ReaderService) fetchBrowserPage(ctx context.Context, sourceURL, target
|
||||
}
|
||||
|
||||
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
|
||||
// 返回 (contentType, status, body, error)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, sourceURL, target string) (string, int, []byte, error) {
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, sourceURL, target)
|
||||
// 返回 (contentType, status, body, error)。按待办 ID 找会话,顺带把响应
|
||||
// Set-Cookie 写回(页面可能靠资源响应续期会话)。
|
||||
func (s *ReaderService) FetchBrowserAsset(ctx context.Context, id, target string) (string, int, []byte, error) {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return "", 0, nil, errors.New("页面已过期,请重新打开")
|
||||
}
|
||||
contentType, status, data, _, err := s.requestBrowserResource(ctx, entry.sourceURL, entry.cookies, target)
|
||||
return contentType, status, data, err
|
||||
}
|
||||
|
||||
// requestBrowserResource 带书源凭据请求一个外部地址。
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, target string) (string, int, []byte, string, error) {
|
||||
func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL string, cookies browserCookieTarget, target string) (string, int, []byte, string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink,让重定向各跳的 Set-Cookie 也能被收集。
|
||||
if cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
state := s.newSourceState(reqCtx, sourceURL)
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
@@ -412,6 +478,8 @@ func (s *ReaderService) requestBrowserResource(ctx context.Context, sourceURL, t
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
// 页面/资源响应下发的 Set-Cookie 也写回会话(含跳转各跳)。
|
||||
cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
@@ -447,8 +515,20 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
if method == "" {
|
||||
method = http.MethodGet
|
||||
}
|
||||
// 段落评论接口:评论页按 page=N 翻页,而接口只认 cursor(见
|
||||
// browser_comment_page.go)。改写发生在补书源凭据之前,Cookie/Referer
|
||||
// 仍然按目标站点选取。
|
||||
pagerKey := ""
|
||||
if entry.comments != nil {
|
||||
target, pagerKey = entry.comments.rewriteRequest(target)
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, browserFetchTimeout)
|
||||
defer cancel()
|
||||
// 挂上 sink:页面自己发起的请求(如扫码登录的轮询/取票跳转)下发的
|
||||
// Set-Cookie 要能落到书源会话里,否则「面板里登录成功」书源却始终未登录。
|
||||
if entry.cookies.state != nil {
|
||||
reqCtx = withCookieSink(reqCtx, &cookieSink{})
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(reqCtx, strings.ToUpper(method), target, strings.NewReader(body))
|
||||
if err != nil {
|
||||
@@ -499,6 +579,8 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
return nil, err
|
||||
}
|
||||
data = helper.DecompressBody(resp, data)
|
||||
// 页面请求同样把 Set-Cookie 写回书源会话(含跳转各跳)。
|
||||
entry.cookies.captureFrom(reqCtx, resp)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "text/plain"
|
||||
@@ -506,6 +588,9 @@ func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target
|
||||
out := &BrowserXHRResult{Status: resp.StatusCode, ContentType: contentType}
|
||||
if isTextualContent(contentType) {
|
||||
out.Body = string(data)
|
||||
if entry.comments != nil && pagerKey != "" {
|
||||
out.Body = entry.comments.observeResponse(pagerKey, out.Body)
|
||||
}
|
||||
} else {
|
||||
out.Body = base64.StdEncoding.EncodeToString(data)
|
||||
out.Base64 = true
|
||||
@@ -658,8 +743,34 @@ func (s *ReaderService) browserAssetSig(id, target string) string {
|
||||
return s.browserSign("asset|" + id + "|" + target)
|
||||
}
|
||||
|
||||
func (s *ReaderService) browserPageURL(id string) string {
|
||||
return "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
// browserPageURL 生成 iframe 承载地址。
|
||||
//
|
||||
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id¶&source
|
||||
// 读出「哪本书的哪一段、哪个来源」,再据此请求 /para_review。承载地址是我们自己的
|
||||
// 代理路径,页面看到的 query 只有 id/s,原地址的参数全丢了——页面于是按缺参数去
|
||||
// 请求评论接口,气泡数照样显示,点开却一条段评都没有。
|
||||
//
|
||||
// 所以把原地址的 query 原样拼在签名参数之后:页面看到的 search 与原站一致。
|
||||
// 我们自己的 id/s 放最前,Gin 取同名的第一个值,不会被原地址里的同名参数顶掉。
|
||||
func (s *ReaderService) browserPageURL(id, target string) string {
|
||||
u := "/api/reader/browser/page?id=" + url.QueryEscape(id) + "&s=" + s.browserPageSig(id)
|
||||
if q := browserTargetQuery(target); q != "" {
|
||||
u += "&" + q
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// browserTargetQuery 取原地址的 query(不含 `?`);data: 地址或解析失败时返回空串。
|
||||
func browserTargetQuery(target string) string {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" || strings.HasPrefix(strings.ToLower(target), "data:") {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.RawQuery
|
||||
}
|
||||
|
||||
// browserAssetProxyURL 生成资源代理地址(iframe 页面内引用用)。
|
||||
@@ -716,6 +827,10 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
||||
if e.sourceID != sourceID {
|
||||
continue
|
||||
}
|
||||
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
|
||||
if e.finished {
|
||||
continue
|
||||
}
|
||||
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
|
||||
if e.userID != "" && userID != "" && e.userID != userID {
|
||||
continue
|
||||
@@ -726,14 +841,20 @@ func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPa
|
||||
}
|
||||
|
||||
// ResolveBrowser 用户完成/取消页面后回传结果,解除 JS 侧的阻塞。
|
||||
//
|
||||
// 交付后立刻把条目移出待办表:open 模式(java.startBrowser)没有别的清理
|
||||
// 路径,留着会一直躺到 TTL 到期——光遇聚合的「❇️ 更新书源」就是这么在
|
||||
// 15 分钟内反复串到其它按钮上的。
|
||||
func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, cancelled bool) error {
|
||||
entry := s.lookupBrowser(id)
|
||||
if entry == nil {
|
||||
return errors.New("页面已过期或已完成")
|
||||
}
|
||||
// 注意锁序:browserMu 必须在 entry.mu 之外获取(pruneBrowsersLocked 是
|
||||
// browserMu → entry.mu),这里不能持着 entry.mu 去等 browserMu。
|
||||
entry.mu.Lock()
|
||||
defer entry.mu.Unlock()
|
||||
if entry.finished {
|
||||
entry.mu.Unlock()
|
||||
return errors.New("该页面已完成")
|
||||
}
|
||||
if cancelled {
|
||||
@@ -749,6 +870,9 @@ func (s *ReaderService) ResolveBrowser(id, otherUserID, body, finalURL string, c
|
||||
if entry.done != nil {
|
||||
close(entry.done)
|
||||
}
|
||||
entry.mu.Unlock()
|
||||
|
||||
s.dropBrowser(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -785,6 +909,14 @@ func (s *ReaderService) pruneBrowsersLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
// browserPageOf 生成前端展示用的描述(自行加锁)。
|
||||
// registerBrowser 之后需要把页面直接回给调用方(如打开一条段评)时用它。
|
||||
func (s *ReaderService) browserPageOf(e *pendingBrowser) BrowserPage {
|
||||
s.browserMu.Lock()
|
||||
defer s.browserMu.Unlock()
|
||||
return s.pageOfLocked(e)
|
||||
}
|
||||
|
||||
// pageOfLocked 生成前端展示用的描述(调用方需持有 browserMu)。
|
||||
func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
target := e.request.URL
|
||||
@@ -795,7 +927,8 @@ func (s *ReaderService) pageOfLocked(e *pendingBrowser) BrowserPage {
|
||||
ID: e.id,
|
||||
Title: e.request.Title,
|
||||
Mode: e.mode,
|
||||
PageURL: s.browserPageURL(e.id),
|
||||
Seq: e.seq,
|
||||
PageURL: s.browserPageURL(e.id, e.request.URL),
|
||||
Refetch: e.request.Refetch,
|
||||
SourceID: e.sourceID,
|
||||
TargetURL: target,
|
||||
|
||||
@@ -167,6 +167,51 @@ func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPageURLKeepsTargetQuery 承载地址必须原样带上目标地址的 query。
|
||||
//
|
||||
// 光遇聚合的段评页从 location.search 读 item_id/para/source,再据此请求评论接口。
|
||||
// 承载地址若只剩我们自己的 id/s,页面就按缺参数去请求:段评气泡上的数字照常有,
|
||||
// 点开却一条评论都没有。
|
||||
func TestBrowserPageURLKeepsTargetQuery(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<html><body>comment page</body></html>`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 目标地址里故意带上与我们同名的 s/id:它们不能顶掉签名参数。
|
||||
target := srv.URL + "/get_para_review?item_id=abc¶=1&source=QQ%E9%98%85%E8%AF%BB&s=decoy&id=decoy"
|
||||
entry, err := svc.registerBrowser(t.Context(), srv.URL, "src-1", readerTestUserID,
|
||||
browserCookieTarget{}, rule.BrowserTask{URL: target, Title: "段评"}, browserModeOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("登记承载页面失败: %v", err)
|
||||
}
|
||||
|
||||
page := svc.browserPageOf(entry)
|
||||
u, err := url.Parse(page.PageURL)
|
||||
if err != nil {
|
||||
t.Fatalf("承载地址不合法: %q", page.PageURL)
|
||||
}
|
||||
q := u.Query()
|
||||
if q.Get("item_id") != "abc" || q.Get("para") != "1" || q.Get("source") != "QQ阅读" {
|
||||
t.Fatalf("承载地址丢了目标参数(段评页会因此取不到评论): %q", page.PageURL)
|
||||
}
|
||||
// 路由按 id/s 取承载的页面快照,同名参数必须仍指向我们自己的待办
|
||||
if q.Get("id") != entry.id || q.Get("s") != svc.browserPageSig(entry.id) {
|
||||
t.Fatalf("同名参数顶掉了签名参数: %q", page.PageURL)
|
||||
}
|
||||
if _, err := svc.VerifyBrowserPage(q.Get("id"), q.Get("s")); err != nil {
|
||||
t.Fatalf("带业务参数后签名校验失败: %v", err)
|
||||
}
|
||||
// 没有 query 的地址(含 data: 页面)不该多出参数
|
||||
for _, plain := range []string{"https://panel.example.com/plain", "data:text/html;base64,AAA"} {
|
||||
if got := svc.browserPageURL("id1", plain); strings.Count(got, "&") != 1 {
|
||||
t.Fatalf("无 query 的地址被拼进了多余参数: %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelInjectsSourceCookies http(s) 页面必须由服务端带书源 Cookie
|
||||
// 抓取,否则「用户后台」在浏览器里永远是未登录状态。
|
||||
func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
@@ -201,7 +246,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
st.flush()
|
||||
|
||||
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -266,7 +311,7 @@ func TestBrowserPanelInjectsSourceCookies(t *testing.T) {
|
||||
if gotSourceURL != srv.URL {
|
||||
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
|
||||
}
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, gotSourceURL, target)
|
||||
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
|
||||
if err != nil {
|
||||
t.Fatalf("拉取资源失败: %v", err)
|
||||
}
|
||||
@@ -313,7 +358,7 @@ func TestBrowserPanelXHRProxy(t *testing.T) {
|
||||
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
|
||||
st.flush()
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, rule.BrowserTask{
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
|
||||
URL: srv.URL + "/user", Title: "用户后台",
|
||||
}); err != nil {
|
||||
t.Fatalf("打开用户后台失败: %v", err)
|
||||
@@ -390,3 +435,46 @@ func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
|
||||
t.Fatal("未解除阻塞")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserPanelOpenPageDroppedAfterResolve open 模式(java.startBrowser)
|
||||
// 的页面在用户关闭后必须立刻离开待办表。
|
||||
//
|
||||
// 回归:这条日志只删除 wait 模式(awaitBrowser 的 defer),open 模式没有清理
|
||||
// 路径,会一直躺到 15 分钟 TTL 到期。前端轮询到它就又把旧页面弹出来——光遇
|
||||
// 聚合点过「❇️ 更新书源」后,接下来点「书源设置」「段评设置」都会跳到书源更新页。
|
||||
func TestBrowserPanelOpenPageDroppedAfterResolve(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
|
||||
|
||||
// 等价于书源的 renderVersionPage():java.startBrowser(data:..., '光遇书源更新')
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>更新</body></html>", Title: "光遇书源更新"}); err != nil {
|
||||
t.Fatalf("登记 open 页面失败: %v", err)
|
||||
}
|
||||
old := waitPending(t, svc, sourceID)
|
||||
if old.Mode != browserModeOpen {
|
||||
t.Fatalf("模式 = %q,期望 open", old.Mode)
|
||||
}
|
||||
|
||||
// 用户关掉页面
|
||||
if err := svc.ResolveBrowser(old.ID, readerTestUserID, "", "", true); err != nil {
|
||||
t.Fatalf("关闭页面失败: %v", err)
|
||||
}
|
||||
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
|
||||
t.Fatalf("关闭后待办未清理: %+v", pages)
|
||||
}
|
||||
|
||||
// 再点别的按钮:待办表里只应剩新页面,且序号更大
|
||||
if err := svc.openBrowser(ctx, "https://panel.example.com", sourceID, readerTestUserID, browserCookieTarget{},
|
||||
rule.BrowserTask{URL: "data:text/html,<html><body>设置</body></html>", Title: "光遇书源设置"}); err != nil {
|
||||
t.Fatalf("登记新页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 || pages[0].Title != "光遇书源设置" {
|
||||
t.Fatalf("待办表应只剩新页面: %+v", pages)
|
||||
}
|
||||
if pages[0].Seq <= old.Seq {
|
||||
t.Fatalf("新页面 seq 应更大: %d <= %d", pages[0].Seq, old.Seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 漫画源(聚合类漫画源就是这么写的)正文规则直接给 <img src="…"> 的 HTML。
|
||||
// 整行当地址会被代理成一堆取不回的「图」,页面上全是破图;必须按标签抽 src。
|
||||
func TestImageRefsInLine(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
line string
|
||||
want []string
|
||||
}{
|
||||
{"纯地址原样返回", "https://cdn.example.com/1.webp", []string{"https://cdn.example.com/1.webp"}},
|
||||
{"双引号 img", `<img src="https://cdn.example.com/2.webp">`, []string{"https://cdn.example.com/2.webp"}},
|
||||
{"单引号 img", `<img src='https://cdn.example.com/3.webp'/>`, []string{"https://cdn.example.com/3.webp"}},
|
||||
{"属性顺序无关", `<img data-x="1" src="https://cdn.example.com/4.webp" class="a">`, []string{"https://cdn.example.com/4.webp"}},
|
||||
{"一行多个标签", `<p><img src="/a/1.webp"><img src='/a/2.webp'></p>`, []string{"/a/1.webp", "/a/2.webp"}},
|
||||
{"img 没有 src 时跳过", `<img alt="空">`, nil},
|
||||
{"非 img 的 HTML 片段跳过", `</div>`, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := imageRefsInLine(c.line)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Fatalf("%s: got %q want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:图片型书籍走 GetContentForBook 时,Images 必须是真正的图片地址,
|
||||
// 不能把 <img> 标签本身当成图片地址(那会让代理取回一堆 HTML,前端全破图)。
|
||||
func TestGetContentForBookExtractsImageSrc(t *testing.T) {
|
||||
page := `<html><body><div class="pages">
|
||||
<img src="/img/1.webp"><img src="/img/2.webp">
|
||||
</div></body></html>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(page))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "漫画测试源",
|
||||
"bookSourceType": 64,
|
||||
"ruleContent": { "content": "class.pages@html" }
|
||||
}`, srv.URL)
|
||||
sourceID := importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "漫画测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "航海王",
|
||||
Type: 2, // 图片
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第1话", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
_ = sourceID
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "image" {
|
||||
t.Fatalf("类型 = %q,期望 image", out.Type)
|
||||
}
|
||||
if len(out.Images) != 2 {
|
||||
t.Fatalf("图片数 = %d,期望 2(%v)", len(out.Images), out.Images)
|
||||
}
|
||||
for i, img := range out.Images {
|
||||
// 书架维度返回的是签名代理地址(浏览器取图带不上防盗链头),
|
||||
// 把里面的原始地址解出来核对,顺便确认没有被塞进 HTML 标签。
|
||||
if !strings.HasPrefix(img, "/api/reader/media?") {
|
||||
t.Fatalf("第 %d 张不是代理地址:%q", i, img)
|
||||
}
|
||||
encoded := img[strings.Index(img, "u=")+2:]
|
||||
if end := strings.IndexByte(encoded, '&'); end >= 0 {
|
||||
encoded = encoded[:end]
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 张地址解不开:%q", i, img)
|
||||
}
|
||||
want := fmt.Sprintf("%s/img/%d.webp", srv.URL, i+1)
|
||||
if string(raw) != want {
|
||||
t.Fatalf("第 %d 张解析出 %q,期望 %q", i, raw, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:正文里的「段评」锚点解析(对齐 legado 的段评机制)。
|
||||
//
|
||||
// legado 侧段评有两种落在正文里的形态:
|
||||
//
|
||||
// 1. 段落里的 <comment ident="评论地址" count="评论数" />:宿主(苹果/源阅分支)
|
||||
// 直接把它解析成段落旁的评论气泡;
|
||||
// 2. 「改版」宿主不认 <comment>,改由书源在正文规则里把它换成一张内嵌 SVG 图片,
|
||||
// 图片地址后面再跟一段 JSON 配置:
|
||||
// <img src="data:image/svg+xml;base64,…{"click":"showCmt('评论地址','平台','段评')","style":"text"}">
|
||||
// legado 识别的是地址后面的这份配置(ReadBookActivity.oldClickImg),点击时把
|
||||
// 配置里的 click/js 当 JS 执行(showCmt 内部再调 java.showBrowser 打开评论页)。
|
||||
//
|
||||
// MeBox 没有 Android 的 Canvas 排版,正文按行渲染,因此这里把两种形态都归一成
|
||||
// 「某一行的段评气泡」:只摘掉标记本身,正文文字一个字都不动,并把评论地址 / 评论数
|
||||
// 结构化下发给前端;点击气泡时前端复用宿主浏览器(带书源 Cookie)打开评论地址。
|
||||
//
|
||||
// 约定:但凡无法确定评论地址的图片,一律原样保留交给既有链路,绝不猜一个地址出来。
|
||||
|
||||
// ContentComment 正文里的一条段评锚点(随正文一起下发给前端)。
|
||||
type ContentComment struct {
|
||||
// Line 段落行号:Content 按 "\n" 拆分后的 0-based 下标。
|
||||
Line int `json:"line"`
|
||||
// Count 评论数(0 表示未知:前端只画一个不带数字的气泡)。
|
||||
Count int `json:"count"`
|
||||
// URL 评论地址(书源 showCmt 的第一个参数),前端点击时交给宿主浏览器打开。
|
||||
URL string `json:"url"`
|
||||
// Label 评论类型:段评 / 本章说,用作气泡提示。
|
||||
Label string `json:"label,omitempty"`
|
||||
// Block 独占一行的整块评论(章末「本章说」),前端按居中整块渲染。
|
||||
Block bool `json:"block,omitempty"`
|
||||
}
|
||||
|
||||
// commentTagRe 匹配正文里的 <comment …> 标签。属性顺序不定、可能自闭合,
|
||||
// 也可能带一个配对结束标签(`<comment …>…</comment>`),一并吃掉。
|
||||
var commentTagRe = regexp.MustCompile(`(?is)<comment\b[^>]*?/?>(?:</comment>)?`)
|
||||
|
||||
// 下面三条属性正则都允许单/双引号两种写法;捕获组 1 是双引号内容,2 是单引号内容。
|
||||
var (
|
||||
commentIdentRe = regexp.MustCompile(`(?is)\bident\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
commentCountRe = regexp.MustCompile(`(?is)\bcount\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
commentOnPressRe = regexp.MustCompile(`(?is)\bonPress\s*=\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
)
|
||||
|
||||
// commentJSURLRe 从 showCmt / showReadingBrowser / startBrowserDp 的调用里取第一个字符串参数。
|
||||
// 苹果分支的 <comment onPress="java.showReadingBrowser('地址',…)" /> 就靠它取地址。
|
||||
// RE2 不支持反向引用,双/单引号两种写法拆成两个捕获组。
|
||||
var commentJSURLRe = regexp.MustCompile(`(?is)(?:showCmt|showReadingBrowser|startBrowserDp)\s*\(\s*(?:"([^"]*)"|'([^']*)')`)
|
||||
|
||||
// imgParamRe 宽容地提取图片地址后面 JSON 配置里的键值对。
|
||||
//
|
||||
// 不用 encoding/json 解析:光遇聚合的「本章说」把配置拼成 {'type':'qtbzs',"click":"…",'style':'FULL'}
|
||||
// (单双引号混用),严格 JSON 解析会失败,而它又是合法 JS 对象字面量。
|
||||
var imgParamRe = regexp.MustCompile(`(?is)["']?(click|js|style|type|width)["']?\s*:\s*(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)')`)
|
||||
|
||||
// svgCountRe 取内嵌段评 SVG 里画在气泡上的数字(书源把评论数直接画进了 SVG 文本)。
|
||||
var svgCountRe = regexp.MustCompile(`(?is)<text[^>]*>\s*([0-9]+)\s*\+?\s*</text>`)
|
||||
|
||||
// wrapMarkupRe 匹配不带内容的块级标签,用来判断一行是不是「只有评论标记」。
|
||||
var wrapMarkupRe = regexp.MustCompile(`(?is)</?(?:p|div|br|hr|h[1-6]|article|dd|dl)\b[^>]*>`)
|
||||
|
||||
// extractContentComments 摘出正文里的段评标记,返回清理后的正文与结构化锚点。
|
||||
//
|
||||
// 只改行内内容、不增删行,因此评论的 Line 与调用方按 "\n" 拆出的下标始终一致。
|
||||
func extractContentComments(content string) (string, []ContentComment) {
|
||||
if content == "" || (!strings.Contains(content, "<comment") && !strings.Contains(content, "<img")) {
|
||||
return content, nil
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
out := make([]string, len(lines))
|
||||
comments := make([]ContentComment, 0, 4)
|
||||
changed := false
|
||||
for i, line := range lines {
|
||||
text, cs := extractLineComments(line, i)
|
||||
out[i] = text
|
||||
if len(cs) > 0 {
|
||||
changed = true
|
||||
comments = append(comments, cs...)
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return content, nil
|
||||
}
|
||||
return strings.Join(out, "\n"), comments
|
||||
}
|
||||
|
||||
// extractLineComments 处理一行:返回去掉段评标记后的文字与该行上的段评。
|
||||
func extractLineComments(line string, lineNo int) (string, []ContentComment) {
|
||||
type marker struct {
|
||||
start, end int
|
||||
comment ContentComment
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
for _, m := range commentTagRe.FindAllStringIndex(line, -1) {
|
||||
if c, ok := parseCommentTag(line[m[0]:m[1]], lineNo); ok {
|
||||
markers = append(markers, marker{m[0], m[1], c})
|
||||
}
|
||||
}
|
||||
for _, tag := range scanImgTags(line) {
|
||||
if c, ok := parseCommentImage(tag.src, lineNo); ok {
|
||||
markers = append(markers, marker{tag.start, tag.end, c})
|
||||
}
|
||||
}
|
||||
if len(markers) == 0 {
|
||||
return line, nil
|
||||
}
|
||||
|
||||
sort.Slice(markers, func(i, j int) bool { return markers[i].start < markers[j].start })
|
||||
comments := make([]ContentComment, 0, len(markers))
|
||||
var b strings.Builder
|
||||
prev := 0
|
||||
for _, mk := range markers {
|
||||
if mk.start < prev { // 与前一个标记重叠(理论上不会出现),跳过避免重复删除
|
||||
continue
|
||||
}
|
||||
b.WriteString(line[prev:mk.start])
|
||||
prev = mk.end
|
||||
c := mk.comment
|
||||
// 摘掉标记后整行只剩标签/空白 → 说明评论独占一行(章末「本章说」)
|
||||
c.Block = isBlankMarkup(line[:mk.start] + line[mk.end:])
|
||||
comments = append(comments, c)
|
||||
}
|
||||
b.WriteString(line[prev:])
|
||||
return b.String(), comments
|
||||
}
|
||||
|
||||
// imgTagMatch 一行里一个 <img> 标签的位置与 src 值。
|
||||
type imgTagMatch struct {
|
||||
start, end int
|
||||
src string
|
||||
}
|
||||
|
||||
// scanImgTags 扫描一行里的所有 <img> 标签,返回标签位置与 src 值。
|
||||
//
|
||||
// 不能用 <img[^>]*src="([^"]*)"> 这类正则:书源把段评 SVG 拼成
|
||||
// <img src="data:…,{"click":"showCmt('…','…')"}"> —— 属性值里的 JSON 又用了双引号,
|
||||
// 正则会在第一个内层引号处截断。这里改为手工扫描:进入花括号后忽略引号,
|
||||
// 只把「花括号之外遇到的引号」当作属性值的结束。
|
||||
func scanImgTags(line string) []imgTagMatch {
|
||||
var out []imgTagMatch
|
||||
lower := strings.ToLower(line)
|
||||
for i := 0; i < len(line); {
|
||||
idx := strings.Index(lower[i:], "<img")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
start := i + idx
|
||||
src, end, ok := scanImgTagSrc(line, start)
|
||||
if !ok {
|
||||
i = start + 4
|
||||
continue
|
||||
}
|
||||
out = append(out, imgTagMatch{start: start, end: end, src: src})
|
||||
i = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scanImgTagSrc 从 <img 起始位置扫描出 src 属性值,并返回标签结束下标(含 '>')。
|
||||
func scanImgTagSrc(line string, start int) (string, int, bool) {
|
||||
lower := strings.ToLower(line)
|
||||
i := start + 4 // 跳过 "<img"
|
||||
for {
|
||||
si := strings.Index(lower[i:], "src")
|
||||
if si < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
si += i
|
||||
q := si + 3
|
||||
for q < len(line) && isHTMLSpace(line[q]) {
|
||||
q++
|
||||
}
|
||||
// 必须是 src= 而不是 srcset= 之类的近似字段
|
||||
if q >= len(line) || line[q] != '=' {
|
||||
i = si + 3
|
||||
continue
|
||||
}
|
||||
q++
|
||||
for q < len(line) && isHTMLSpace(line[q]) {
|
||||
q++
|
||||
}
|
||||
if q >= len(line) || (line[q] != '"' && line[q] != '\'') {
|
||||
i = si + 3
|
||||
continue
|
||||
}
|
||||
quote := line[q]
|
||||
valStart := q + 1
|
||||
depth := 0
|
||||
j := valStart
|
||||
for ; j < len(line); j++ {
|
||||
c := line[j]
|
||||
switch {
|
||||
case c == '{':
|
||||
depth++
|
||||
case c == '}' && depth > 0:
|
||||
depth--
|
||||
case c == quote && depth == 0:
|
||||
// 花括号之外的引号才是属性值的结束
|
||||
gt := strings.IndexByte(line[j:], '>')
|
||||
if gt < 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
return line[valStart:j], j + gt + 1, true
|
||||
}
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// isHTMLSpace 判断 HTML 属性之间的空白(含换行/制表)。
|
||||
func isHTMLSpace(c byte) bool {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r'
|
||||
}
|
||||
|
||||
// parseCommentTag 解析宿主侧的 <comment ident count /> 形式。
|
||||
func parseCommentTag(tag string, lineNo int) (ContentComment, bool) {
|
||||
rawURL := attrOf(commentIdentRe, tag)
|
||||
if rawURL == "" {
|
||||
rawURL = attrOf(commentOnPressRe, tag)
|
||||
}
|
||||
rawURL = extractCommentURL(rawURL)
|
||||
if rawURL == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
count, _ := strconv.Atoi(strings.TrimSpace(attrOf(commentCountRe, tag)))
|
||||
return ContentComment{Line: lineNo, Count: count, URL: rawURL, Label: "段评"}, true
|
||||
}
|
||||
|
||||
// parseCommentImage 解析「图片地址 + JSON 配置」形式的段评。
|
||||
//
|
||||
// 只把真正指向评论的气泡当段评:地址要么来自 showCmt/showReadingBrowser,
|
||||
// 要么图片本身就是内嵌的段评 SVG。其它带 click 配置的图片保持原样,避免误伤。
|
||||
func parseCommentImage(src string, lineNo int) (ContentComment, bool) {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
base, params := splitImgSrcParams(src)
|
||||
if len(params) == 0 {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
action := params["click"]
|
||||
if action == "" {
|
||||
action = params["js"]
|
||||
}
|
||||
if action == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
isCommentCall := strings.Contains(action, "showCmt") || strings.Contains(action, "showReadingBrowser")
|
||||
if !isCommentCall && !strings.HasPrefix(base, "data:image/svg+xml") {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
rawURL := extractCommentURL(action)
|
||||
if rawURL == "" {
|
||||
return ContentComment{}, false
|
||||
}
|
||||
label := "段评"
|
||||
if strings.Contains(action, "本章说") {
|
||||
label = "本章说"
|
||||
}
|
||||
return ContentComment{
|
||||
Line: lineNo,
|
||||
Count: commentCountFromImage(base),
|
||||
URL: rawURL,
|
||||
Label: label,
|
||||
}, true
|
||||
}
|
||||
|
||||
// splitImgSrcParams 把图片地址拆成(地址, JSON 配置),没有配置时返回 (src, nil)。
|
||||
//
|
||||
// 对应 legado 的 AnalyzeUrl.paramPattern `\s*,\s*(?=\{)`:地址与配置之间用「逗号 + 花括号」分隔。
|
||||
func splitImgSrcParams(src string) (string, map[string]string) {
|
||||
for i := 0; i < len(src); i++ {
|
||||
if src[i] != ',' {
|
||||
continue
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(src) && (src[j] == ' ' || src[j] == '\t' || src[j] == '\n' || src[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j >= len(src) || src[j] != '{' {
|
||||
continue
|
||||
}
|
||||
end := strings.LastIndexByte(src, '}')
|
||||
if end <= j {
|
||||
continue
|
||||
}
|
||||
if params := parseImgParams(src[j : end+1]); len(params) > 0 {
|
||||
return strings.TrimSpace(src[:i]), params
|
||||
}
|
||||
}
|
||||
return src, nil
|
||||
}
|
||||
|
||||
// parseImgParams 宽容解析图片配置里的键值对(见 imgParamRe 的说明)。
|
||||
func parseImgParams(raw string) map[string]string {
|
||||
ms := imgParamRe.FindAllStringSubmatch(raw, -1)
|
||||
if len(ms) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(ms))
|
||||
for _, m := range ms {
|
||||
val := m[2]
|
||||
if val == "" {
|
||||
val = m[3]
|
||||
}
|
||||
val = strings.ReplaceAll(val, `\"`, `"`)
|
||||
val = strings.ReplaceAll(val, `\'`, `'`)
|
||||
out[strings.ToLower(m[1])] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// commentCountFromImage 从内嵌段评 SVG 里取评论数(书源把数字画进了 SVG 文本)。
|
||||
// 取不到时返回 0,前端只画一个不带数字的气泡。
|
||||
func commentCountFromImage(src string) int {
|
||||
decoded, ok := decodeCommentDataURI(src)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
m := svgCountRe.FindStringSubmatch(decoded)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(m[1])
|
||||
if err != nil || n < 0 {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// decodeCommentDataURI 解出 data: URI 的文本内容(支持 base64 与明文两种)。
|
||||
func decodeCommentDataURI(s string) (string, bool) {
|
||||
if !strings.HasPrefix(s, "data:") {
|
||||
return "", false
|
||||
}
|
||||
comma := strings.IndexByte(s, ',')
|
||||
if comma < 0 {
|
||||
return "", false
|
||||
}
|
||||
meta, payload := s[:comma], s[comma+1:]
|
||||
if strings.HasSuffix(strings.ToLower(meta), ";base64") {
|
||||
b, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
return payload, true
|
||||
}
|
||||
|
||||
// extractCommentURL 从 showCmt/showReadingBrowser 调用或裸地址里取评论地址。
|
||||
func extractCommentURL(js string) string {
|
||||
js = strings.TrimSpace(js)
|
||||
if js == "" {
|
||||
return ""
|
||||
}
|
||||
if m := commentJSURLRe.FindStringSubmatch(js); m != nil {
|
||||
return trimCommentURL(firstSubmatch(m))
|
||||
}
|
||||
if strings.HasPrefix(js, "http://") || strings.HasPrefix(js, "https://") {
|
||||
return trimCommentURL(js)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// attrOf 取属性值,双引号优先、单引号兜底。
|
||||
func attrOf(re *regexp.Regexp, s string) string {
|
||||
m := re.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return firstSubmatch(m)
|
||||
}
|
||||
|
||||
// firstSubmatch 返回「双引号 / 单引号」两个捕获组里非空的那个。
|
||||
func firstSubmatch(m []string) string {
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return m[1]
|
||||
}
|
||||
if len(m) > 2 {
|
||||
return m[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// trimCommentURL 去空白并还原 HTML 实体。
|
||||
// 苹果/源阅分支会把地址里的 & 转成 &(paraForiOS),直接拿去请求会 404。
|
||||
func trimCommentURL(raw string) string {
|
||||
r := strings.NewReplacer(
|
||||
""", `"`, """, `"`,
|
||||
"'", "'", "'", "'",
|
||||
"<", "<", ">", ">",
|
||||
"&", "&",
|
||||
)
|
||||
return strings.TrimSpace(r.Replace(raw))
|
||||
}
|
||||
|
||||
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
|
||||
func isBlankMarkup(s string) bool {
|
||||
s = wrapMarkupRe.ReplaceAllString(s, "")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
s = strings.ReplaceAll(s, "\u3000", " ")
|
||||
return strings.TrimSpace(s) == ""
|
||||
}
|
||||
|
||||
// ─── 打开段评(宿主浏览器承载) ─────────────────────────────────────────────
|
||||
|
||||
// OpenContentComment 在宿主浏览器里打开一条段评。
|
||||
//
|
||||
// 书源点击段评气泡时执行的是它自己的 showCmt(...):先 java.ajax 取评论页,
|
||||
// 再 java.showBrowser 展示。MeBox 不跑书源 JS,而是把 showCmt 的第一个参数
|
||||
// (评论地址)解析出来(见本文件),复用承载登录页的那套宿主浏览器打开:
|
||||
// 服务端带书源 Cookie 抓取评论页,前端 iframe 展示。这样评论接口需要的登录态
|
||||
// 仍然生效——前端直接 window.open 会以未登录身份访问。
|
||||
//
|
||||
// 返回的 BrowserPage 直接给前端渲染,不必走轮询(段评只需展示、无需回传 DOM)。
|
||||
func (s *ReaderService) OpenContentComment(ctx context.Context, userID, bookID, rawURL, title string) (*BrowserPage, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("阅读服务未就绪")
|
||||
}
|
||||
target, err := validateCommentURL(rawURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
book, err := s.repo.GetBook(ctx, bookID)
|
||||
if err != nil || book == nil {
|
||||
return nil, fmt.Errorf("书籍不存在或已移出书架")
|
||||
}
|
||||
sourceID := ""
|
||||
if src, findErr := s.repo.GetSourceByURL(ctx, book.Origin); findErr == nil && src != nil {
|
||||
sourceID = src.ID
|
||||
}
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "段评"
|
||||
}
|
||||
entry, err := s.registerBrowser(ctx, book.Origin, sourceID, userID,
|
||||
// 段评是「只展示」的页面,没有登录会话可回写 Cookie。
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: target, Title: title}, browserModeOpen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := s.browserPageOf(entry)
|
||||
return &page, nil
|
||||
}
|
||||
|
||||
// validateCommentURL 校验评论地址:只允许 http(s),并挡住内网地址。
|
||||
//
|
||||
// 段评地址来自书源正文,会带着书源登录态由服务端去抓;不做限制就等于给了一个
|
||||
// 「以书源身份请求任意地址」的口子(SSRF)。
|
||||
func validateCommentURL(raw string) (string, error) {
|
||||
u := strings.TrimSpace(raw)
|
||||
if u == "" {
|
||||
return "", fmt.Errorf("评论地址为空")
|
||||
}
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return "", fmt.Errorf("评论地址无效:%s", truncateForLog(u, 80))
|
||||
}
|
||||
if isPrivateHost(parsed.Hostname()) {
|
||||
return "", fmt.Errorf("评论地址指向内网,已拒绝")
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// isPrivateHost 判断主机名是否是回环/内网/链路本地地址。
|
||||
func isPrivateHost(host string) bool {
|
||||
h := strings.ToLower(strings.Trim(host, "[]"))
|
||||
if h == "" || h == "localhost" || strings.HasSuffix(h, ".localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(h)
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || ip.IsLinkLocalUnicast()
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 段评解析单测:覆盖 legado 的两种正文形态(<comment /> 与「图片地址 + JSON 配置」),
|
||||
// 以及「绝不能吞掉或破坏正文文字」这条硬约束。
|
||||
|
||||
// svgCommentSrc 按光遇聚合 createSvg 的写法拼一张带评论数气泡图:
|
||||
// data:image/svg+xml;base64,<svg>,{"click":"showCmt('地址','平台','段评')","style":"text"}
|
||||
//
|
||||
// 注意第二个逗号后面直接跟 JSON,且 JSON 自身用双引号——它嵌在双引号属性里,
|
||||
// 正是会打断普通正则的「非法 HTML」,解析必须扛得住。
|
||||
func svgCommentSrc(t *testing.T, count int, click string, style string) string {
|
||||
t.Helper()
|
||||
svg := fmt.Sprintf(
|
||||
`<svg xmlns='http://www.w3.org/2000/svg'><text x='1' y='2'>%d</text></svg>`, count)
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(svg))
|
||||
return fmt.Sprintf(`data:image/svg+xml;base64,%s,{"click":%q,"style":%q}`, b64, click, style)
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsLegacyTag(t *testing.T) {
|
||||
content := `<p>第一段<comment ident="https://cdn.example.com/c/1" count="12" /></p>
|
||||
<p>第二段</p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>第一段</p>\n<p>第二段</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 0 || c.Count != 12 || c.URL != "https://cdn.example.com/c/1" || c.Label != "段评" {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
if c.Block {
|
||||
t.Fatalf("段落内的段评不应标记为整块:%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsIOSOnPress(t *testing.T) {
|
||||
content := `<p>正文<comment count="7" onPress="java.showReadingBrowser('https://cdn.example.com/c/2?a=1&b=2','番茄段评')"></comment></p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
// & 必须还原成 &,否则请求评论接口会 404
|
||||
if comments[0].URL != "https://cdn.example.com/c/2?a=1&b=2" || comments[0].Count != 7 {
|
||||
t.Fatalf("解析结果不符:%+v", comments[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsInlineImage(t *testing.T) {
|
||||
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1&item_id=2','番茄','段评')", "text")
|
||||
content := `<p>正文内容<img src="` + src + `"></p>
|
||||
<p>下一段</p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文内容</p>\n<p>下一段</p>" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 0 || c.Count != 3 || c.Label != "段评" {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
// 地址里的 & 原样保留(JSON 里的 & 不是实体)
|
||||
if c.URL != "https://v1.example.com/get_review?book_id=1&item_id=2" {
|
||||
t.Fatalf("评论地址 = %q", c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsChapterEndBlock(t *testing.T) {
|
||||
// 章末「本章说」:单独一行,配置用单引号键、双引号值(合法 JS 对象字面量,非法 JSON)
|
||||
line := `<img src="https://v1.example.com/chapter_review_svg?book_id=1&img=1,` +
|
||||
`{'type':'qtbzs',` +
|
||||
`"click":"showCmt('https://v1.example.com/get_review?book_id=1','番茄','本章说' )",` +
|
||||
`'style':'FULL'}">`
|
||||
content := "<p>正文最后一段</p>\n" + line
|
||||
text, comments := extractContentComments(content)
|
||||
if text != "<p>正文最后一段</p>\n" {
|
||||
t.Fatalf("正文被破坏:%q", text)
|
||||
}
|
||||
if len(comments) != 1 {
|
||||
t.Fatalf("评论数 = %d,期望 1", len(comments))
|
||||
}
|
||||
c := comments[0]
|
||||
if c.Line != 1 || c.Label != "本章说" || !c.Block {
|
||||
t.Fatalf("解析结果不符:%+v", c)
|
||||
}
|
||||
if c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("评论地址 = %q", c.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsCountOver99(t *testing.T) {
|
||||
// 书源把 >99 的评论数显示成 "99+",解析要取到 99
|
||||
src := svgCommentSrc(t, 99, "showCmt('https://x.example.com/c','番茄','段评')", "text")
|
||||
// 手工把文本改成 99+
|
||||
src = strings.Replace(src, ">99<", ">99+<", 1)
|
||||
_, comments := extractContentComments(`<p>x<img src="` + src + `"></p>`)
|
||||
if len(comments) != 1 || comments[0].Count != 99 {
|
||||
t.Fatalf("期望解析出 99,得到 %+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有任何段评标记时,正文必须逐字节不变(不能因为「看起来像 HTML」就动手)。
|
||||
func TestExtractContentCommentsPreservesPlainContent(t *testing.T) {
|
||||
content := `<p>普通段落</p>
|
||||
<p>带插图的段落<img src="https://cdn.example.com/a.jpg"></p>
|
||||
<p>带 onclick 图但非段评<img src="https://cdn.example.com/b.jpg,{'click':'openPhoto(1)'}"></p>`
|
||||
text, comments := extractContentComments(content)
|
||||
if text != content {
|
||||
t.Fatalf("正文被改动:\n got %q\nwant %q", text, content)
|
||||
}
|
||||
if len(comments) != 0 {
|
||||
t.Fatalf("不应解析出段评:%+v", comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractContentCommentsEmpty(t *testing.T) {
|
||||
if text, comments := extractContentComments(""); text != "" || comments != nil {
|
||||
t.Fatalf("空正文应原样返回,得到 %q / %+v", text, comments)
|
||||
}
|
||||
if text, comments := extractContentComments("<p>只有普通正文</p>"); text != "<p>只有普通正文</p>" || comments != nil {
|
||||
t.Fatalf("无标记正文应原样返回,得到 %q / %+v", text, comments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitImgSrcParams(t *testing.T) {
|
||||
base, params := splitImgSrcParams(`data:image/svg+xml;base64,QUJD,{"click":"showCmt('u','x')","style":"text"}`)
|
||||
if base != "data:image/svg+xml;base64,QUJD" {
|
||||
t.Fatalf("base = %q", base)
|
||||
}
|
||||
if params["click"] != "showCmt('u','x')" || params["style"] != "text" {
|
||||
t.Fatalf("params = %+v", params)
|
||||
}
|
||||
if base, params := splitImgSrcParams("https://x.example.com/a.jpg"); params != nil || base != "https://x.example.com/a.jpg" {
|
||||
t.Fatalf("无配置时不应拆出参数:%q / %+v", base, params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCommentURL(t *testing.T) {
|
||||
bad := []string{
|
||||
"", "not-a-url", "file:///etc/passwd", "javascript:alert(1)",
|
||||
"http://localhost/c", "http://127.0.0.1/c", "http://192.168.1.1/c", "http://[::1]/c",
|
||||
}
|
||||
for _, u := range bad {
|
||||
if _, err := validateCommentURL(u); err == nil {
|
||||
t.Fatalf("%q 应被拒绝", u)
|
||||
}
|
||||
}
|
||||
good := "https://v1.qingtian618.com/get_review?book_id=1&item_id=2"
|
||||
if got, err := validateCommentURL(good); err != nil || got != good {
|
||||
t.Fatalf("合法地址被拒:%q / %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:书源正文里带段评标记时,GetContentForBook 应清掉标记、
|
||||
// 下发结构化 Comments,且正文文字完好。
|
||||
func TestGetContentForBookExtractsComments(t *testing.T) {
|
||||
src := svgCommentSrc(t, 5, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
|
||||
// 服务端按 hex 还原规则返回值里的正文;这里让正文规则直接返回一段放进 data 信封的 JSON。
|
||||
pageJSON := fmt.Sprintf(`{"content":%q}`, "<p>第一段<img src=\""+src+"\"></p>\n<p>第二段</p>")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(pageJSON))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "段评测试源",
|
||||
"bookSourceType": 0,
|
||||
"ruleContent": { "content": "$.content" }
|
||||
}`, srv.URL)
|
||||
importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "段评测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "测试书",
|
||||
Type: 0,
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
|
||||
out, err := svc.GetContentForBook(ctx, "u1", book.ID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text", out.Type)
|
||||
}
|
||||
if strings.Contains(out.Content, "<img") || strings.Contains(out.Content, "showCmt") {
|
||||
t.Fatalf("段评标记没有清干净:%q", out.Content)
|
||||
}
|
||||
// 块级标签折成换行后,段落文字一个字都不少(多余空行已压掉)。
|
||||
if out.Content != "第一段\n第二段" {
|
||||
t.Fatalf("正文被破坏:%q", out.Content)
|
||||
}
|
||||
if strings.Contains(out.Content, "<p>") || strings.Contains(out.Content, "</p>") {
|
||||
t.Fatalf("块级标签没有折行:%q", out.Content)
|
||||
}
|
||||
if len(out.Comments) != 1 {
|
||||
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
|
||||
}
|
||||
c := out.Comments[0]
|
||||
if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("段评内容不符:%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// setupTextChapterBook 搭一个「正文规则直接返回指定字符串」的文本源 + 一本书,
|
||||
// 返回服务实例与书籍 ID,供正文链路回归测试复用。
|
||||
func setupTextChapterBook(t *testing.T, content string) (*ReaderService, string) {
|
||||
t.Helper()
|
||||
pageJSON := fmt.Sprintf(`{"content":%q}`, content)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(pageJSON))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
srcJSON := fmt.Sprintf(`{
|
||||
"bookSourceUrl": %q,
|
||||
"bookSourceName": "正文链路测试源",
|
||||
"bookSourceType": 0,
|
||||
"ruleContent": { "content": "$.content" }
|
||||
}`, srv.URL)
|
||||
importTestSource(t, svc, srcJSON, srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "正文链路测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
Name: "测试书",
|
||||
Type: 0,
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{
|
||||
{Index: 0, Title: "第一章", URL: srv.URL + "/book/1/c1.html"},
|
||||
}); err != nil {
|
||||
t.Fatalf("写入章节失败: %v", err)
|
||||
}
|
||||
return svc, book.ID
|
||||
}
|
||||
|
||||
// TestGetContentForBookKeepsTextWithStandaloneImage 回归问题 A:带 <p> 正文的章节
|
||||
// 混入一行独立普通插图时,仍然必须是文本类型、正文一个字都不能丢
|
||||
// (旧实现把 <p>正文</p> 当孤立标签跳过,整章被误判成图片章清空)。
|
||||
func TestGetContentForBookKeepsTextWithStandaloneImage(t *testing.T) {
|
||||
content := "<p>第一段正文</p>\n" +
|
||||
"<p>第二段正文</p>\n" +
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text(正文不能被当成图片章)", out.Type)
|
||||
}
|
||||
if !strings.Contains(out.Content, "第一段正文") || !strings.Contains(out.Content, "第二段正文") {
|
||||
t.Fatalf("正文文字丢失:%q", out.Content)
|
||||
}
|
||||
if !strings.HasPrefix(out.Content, "第一段正文\n第二段正文\n"+imgMarkerPrefix) {
|
||||
t.Fatalf("正文结构不符:%q", out.Content)
|
||||
}
|
||||
if len(out.Images) != 0 {
|
||||
t.Fatalf("文本类型的 Images 应为空,得到 %v", out.Images)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetContentForBookStripsBlockTagsKeepsText 回归问题 B:段评开启时正文是
|
||||
// <p>正文<img …,{click}> 形态,下发/显示的文本里不能残留块级标签,且文字一字不少。
|
||||
func TestGetContentForBookStripsBlockTagsKeepsText(t *testing.T) {
|
||||
src := svgCommentSrc(t, 3, "showCmt('https://v1.example.com/get_review?book_id=1','番茄','段评')", "text")
|
||||
content := "<p>第一段正文</p>\n" +
|
||||
"<p>第二段正文<img src=\"" + src + "\"></p>\n" +
|
||||
"<p>第三段<br>折行后的文字</p>\n" +
|
||||
`<img src="https://cdn.example.com/pic.jpg">`
|
||||
svc, bookID := setupTextChapterBook(t, content)
|
||||
|
||||
out, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("取正文失败: %v", err)
|
||||
}
|
||||
if out.Type != "text" {
|
||||
t.Fatalf("类型 = %q,期望 text", out.Type)
|
||||
}
|
||||
t.Logf("最终下发/显示的正文 = %q", out.Content)
|
||||
t.Logf("段评锚点 = %+v", out.Comments)
|
||||
for _, tag := range []string{"<p>", "</p>", "<br>", "<img", "showCmt"} {
|
||||
if strings.Contains(out.Content, tag) {
|
||||
t.Fatalf("显示文本里残留了 %q:%q", tag, out.Content)
|
||||
}
|
||||
}
|
||||
// 正文文字一字不少(含 <br> 折出来的那一段)。
|
||||
for _, want := range []string{"第一段正文", "第二段正文", "折行后的文字"} {
|
||||
if !strings.Contains(out.Content, want) {
|
||||
t.Fatalf("正文文字丢失 %q:%q", want, out.Content)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out.Content, imgMarkerPrefix) {
|
||||
t.Fatalf("普通插图应保留成 %s 标记:%q", imgMarkerPrefix, out.Content)
|
||||
}
|
||||
if len(out.Comments) != 1 {
|
||||
t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
|
||||
}
|
||||
if out.Comments[0].URL != "https://v1.example.com/get_review?book_id=1" {
|
||||
t.Fatalf("段评地址不符:%+v", out.Comments[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestContentRuleJSReceivesJoinedString 覆盖拷贝漫画(优++)的正文规则写法:
|
||||
//
|
||||
// $..url
|
||||
// <js>result.split("\n").map(x=>'<img src="'+x+'">').join("\n")</js>
|
||||
//
|
||||
// JSON 段先命中「多张图片的 url 列表」,JS 段拿到的必须是按 \n 拼好的字符串
|
||||
// (对应 legado BookContent 用 analyzeRule.getString 求值正文规则),
|
||||
// 喂成数组就会 TypeError: Object has no member 'split',整章图片全取不到。
|
||||
func TestContentRuleJSReceivesJoinedString(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
const srcURL = "https://www.mangacopy.com/"
|
||||
const contentRule = "$..url\n<js>result.split(\"\\n\").map(x=>'<img src=\"'+x+'\">').join(\"\\n\")</js>"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "拷贝漫画(优++)", SourceURL: srcURL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srcURL,
|
||||
RuleContent: &ContentRule{Content: strPtr(contentRule)},
|
||||
Header: &header,
|
||||
}
|
||||
|
||||
body := `{"results":{"chapter":{"contents":[` +
|
||||
`{"url":"https://img.example.com/1.webp"},` +
|
||||
`{"url":"https://img.example.com/2.webp"}]}}}`
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
ar := sess.newAnalyzer("", 0, body, "https://api.mangacopy.com/api/v3/chapter/x")
|
||||
|
||||
got, err := ar.GetString(contentRule, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("正文规则求值失败: %v", err)
|
||||
}
|
||||
want := "<img src=\"https://img.example.com/1.webp\">\n<img src=\"https://img.example.com/2.webp\">"
|
||||
if got != want {
|
||||
t.Fatalf("正文 = %q\n期望 %q", got, want)
|
||||
}
|
||||
// 图片提取(漫画分支):每行一个 <img>,应抽出 2 个真实地址
|
||||
refs := 0
|
||||
for _, line := range strings.Split(got, "\n") {
|
||||
refs += len(imageRefsInLine(line))
|
||||
}
|
||||
if refs != 2 {
|
||||
t.Fatalf("从正文抽出 %d 张图,期望 2", refs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// 本文件:响应 Set-Cookie 回写会话的回归测试(cookies.go)。
|
||||
//
|
||||
// 背景:扫码登录类书源把凭证放在**跳转链中间那一跳**的 Set-Cookie 里
|
||||
// (B站 的 crossDomain 票据地址就是如此),并且签到站点会声明业务域的
|
||||
// Domain(Domain=.bilibili.com)。之前的实现只看最终响应、按请求地址归档,
|
||||
// 于是凭证永远进不了会话,表现为「面板显示已授权,书源却始终未登录」。
|
||||
|
||||
// cookieCaptureServer 构造跳转链:/redeem 下发声明了 Domain 的凭证并 302,
|
||||
// /done 只回 200。这样「中间那一跳的 Set-Cookie」与「最终响应的 Set-Cookie」
|
||||
// 能被分开断言。
|
||||
func cookieCaptureServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/redeem", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "SESSDATA", Value: "cred-abc", Domain: ".example.com", Path: "/",
|
||||
})
|
||||
http.Redirect(w, r, "/done", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/done", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "biz", Value: "1", Path: "/"})
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
})
|
||||
mux.HandleFunc("/plain", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>面板</body></html>`))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
// TestExecuteCapturesRedirectHopCookies 主链路:跳转中间那一跳的 Set-Cookie
|
||||
// 必须被收下,且按 Cookie 自己的 Domain 归档(而不是按请求地址)。
|
||||
func TestExecuteCapturesRedirectHopCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, true); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 凭证声明的是 .example.com,就应该落在 example.com 这个域桶里。
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("跳转中间那一跳的凭证没有按 Domain 归档: %q", got)
|
||||
}
|
||||
// 最终响应的 Cookie 按该跳地址归档。
|
||||
if got := state.GetCookie(srv.URL); !strings.Contains(got, "biz=1") {
|
||||
t.Fatalf("最终响应的 Cookie 没有被收下: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteSkipsCookieCaptureWhenJarDisabled enabledCookieJar=false 时
|
||||
// 主链路不应自动累积 Cookie。
|
||||
func TestExecuteSkipsCookieCaptureWhenJarDisabled(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
state := svc.newSourceState(ctx, srv.URL)
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: srv.URL + "/redeem", Headers: map[string]string{}}
|
||||
if _, _, _, err := svc.executeWithState(ctx, req, state, false); err != nil {
|
||||
t.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
if got := state.GetCookie(srv.URL); got != "" {
|
||||
t.Fatalf("关掉 CookieJar 后不应自动累积: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRCapturesCookies 面板链路:页面自己发起的请求(经
|
||||
// ProxyBrowserXHR 转发)拿到的 Set-Cookie 也要写回书源会话并能落库。
|
||||
// 这正是「页面里登录成功、书源却始终未登录」的那个缺口。
|
||||
func TestProxyBrowserXHRCapturesCookies(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceURL = "https://api.example.com"
|
||||
const sourceID = "panel-source-1"
|
||||
state := svc.newSourceState(ctx, sourceURL)
|
||||
|
||||
// 承载页面本身不下发凭证(/plain),确保断言只针对代理请求。
|
||||
if err := svc.openBrowser(ctx, sourceURL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{state: state},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
|
||||
// 页面请求票据地址:302 时下发凭证,最终响应只带回 JSON。
|
||||
if _, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/redeem", nil, ""); err != nil {
|
||||
t.Fatalf("代理请求失败: %v", err)
|
||||
}
|
||||
if got := state.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板请求的凭证没有写回会话: %q", got)
|
||||
}
|
||||
|
||||
// 登录态必须能持久化:重新读一条会话也应看到这份 Cookie。
|
||||
state.flush()
|
||||
reloaded := svc.newSourceState(ctx, sourceURL)
|
||||
if got := reloaded.GetCookie("https://example.com/"); !strings.Contains(got, "SESSDATA=cred-abc") {
|
||||
t.Fatalf("面板拿到的凭证没有落库: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyBrowserXHRWithoutSessionSkipsCapture 没有会话可回写时(如段评面板)
|
||||
// 代理请求照常工作,只是不做 Cookie 累积。
|
||||
func TestProxyBrowserXHRWithoutSessionSkipsCapture(t *testing.T) {
|
||||
srv := cookieCaptureServer(t)
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
const sourceID = "panel-source-2"
|
||||
|
||||
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID,
|
||||
browserCookieTarget{},
|
||||
rule.BrowserTask{URL: srv.URL + "/plain", Title: "面板"}); err != nil {
|
||||
t.Fatalf("登记页面失败: %v", err)
|
||||
}
|
||||
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("应登记 1 个待办页面,实际 %d", len(pages))
|
||||
}
|
||||
res, err := svc.ProxyBrowserXHR(ctx, pages[0].ID, http.MethodGet, srv.URL+"/done", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("代理请求应照常成功: %v", err)
|
||||
}
|
||||
if res.Status != http.StatusOK {
|
||||
t.Fatalf("代理状态 = %d", res.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrowserHostCookieTargetRespectsJarFlag 面板链路同样受 enabledCookieJar 控制。
|
||||
func TestBrowserHostCookieTargetRespectsJarFlag(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
if got := (&browserHost{state: st, capture: false}).cookieTarget(); got.state != nil {
|
||||
t.Fatal("enabledCookieJar=false 时不应把会话交给面板回写 Cookie")
|
||||
}
|
||||
if got := (&browserHost{state: st, capture: true}).cookieTarget(); got.state != st {
|
||||
t.Fatal("开启时应把会话交给面板回写 Cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceStateCookieConcurrency 面板里的资源/接口代理是并发回写 Cookie 的,
|
||||
// 必须与书源 JS 的读取安全共存(-race 下能抓到缺锁)。
|
||||
func TestSourceStateCookieConcurrency(t *testing.T) {
|
||||
st := &sourceState{cookies: map[string]string{}}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 16; i++ {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
url := "https://example.com/"
|
||||
st.SetCookie(url, "k=1")
|
||||
_ = st.GetCookie(url)
|
||||
_ = st.snapshotCookies()
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
if got := st.GetCookie("https://example.com/"); !strings.Contains(got, "k=1") {
|
||||
t.Fatalf("并发写入后应能读到 Cookie: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件:把响应下发的 Set-Cookie 收进书源会话。
|
||||
//
|
||||
// 之前只有 executeWithState 收 Set-Cookie,而且只看**最终**响应、按请求地址归档。
|
||||
// 对「靠跳转链下发凭证」的登录源(典型是扫码登录:票据地址一跳 302 带
|
||||
// Set-Cookie,再跳到业务域名)这三个做法都会漏:
|
||||
//
|
||||
// 1. 凭证在中间某一跳的 Set-Cookie 里,只看最终响应拿不到;
|
||||
// 2. 签发站点(如 passport.biligame.com)声明的 Domain 可能是业务域
|
||||
// (Domain=.bilibili.com),按请求地址归档会存进错误的域桶,
|
||||
// 之后 api.bilibili.com 的请求取不到这份 Cookie;
|
||||
// 3. 浏览器面板里的页面自己发起的请求走 ProxyBrowserXHR,完全不落库,
|
||||
// 页面在面板里「登录成功」但书源会话始终是空的。
|
||||
//
|
||||
// 因此统一成:一个 cookieSink 收集一次请求(含各跳)的 Set-Cookie,
|
||||
// 再按「Cookie 自身的 Domain 优先、否则该跳请求地址」写入会话。
|
||||
|
||||
// capturedCookie 一条待写入会话的 Cookie 及其归属地址。
|
||||
type capturedCookie struct {
|
||||
// url 归属地址:Cookie 声明了 Domain 时用 Domain(去掉前导点),
|
||||
// 否则用下发它的那一跳的请求地址。
|
||||
url string
|
||||
cookie string
|
||||
}
|
||||
|
||||
// cookieSink 收集一次请求(含重定向各跳)下发的 Set-Cookie。
|
||||
// 并发安全:面板里的多个资源/接口请求会同时走这里。
|
||||
type cookieSink struct {
|
||||
mu sync.Mutex
|
||||
items []capturedCookie
|
||||
}
|
||||
|
||||
// add 收集一份响应里的 Set-Cookie。reqURL 是产生该响应(或跳转前那一跳)的地址。
|
||||
func (s *cookieSink) add(reqURL *url.URL, resp *http.Response) {
|
||||
if s == nil || resp == nil {
|
||||
return
|
||||
}
|
||||
items := make([]capturedCookie, 0, len(resp.Cookies()))
|
||||
for _, ck := range resp.Cookies() {
|
||||
if ck == nil || ck.Name == "" {
|
||||
continue
|
||||
}
|
||||
target := ""
|
||||
if d := strings.TrimSpace(ck.Domain); d != "" {
|
||||
target = strings.TrimPrefix(d, ".")
|
||||
} else if reqURL != nil {
|
||||
target = reqURL.String()
|
||||
}
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, capturedCookie{url: target, cookie: ck.Name + "=" + ck.Value})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.items = append(s.items, items...)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// drain 取出并清空已收集的 Cookie。
|
||||
func (s *cookieSink) drain() []capturedCookie {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := s.items
|
||||
s.items = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// apply 把收集到的 Cookie 写入会话并清空(会话为 nil 时直接丢弃)。
|
||||
func (s *cookieSink) apply(state *sourceState) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
items := s.drain()
|
||||
if state == nil || len(items) == 0 {
|
||||
return
|
||||
}
|
||||
for _, it := range items {
|
||||
state.SetCookie(it.url, it.cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 请求级挂载:sink 放进 context,由 CheckRedirect 逐跳收集 ──────────────
|
||||
|
||||
type cookieSinkKey struct{}
|
||||
|
||||
// withCookieSink 在请求 context 上挂一个 sink;sink 为 nil 时原样返回。
|
||||
func withCookieSink(ctx context.Context, sink *cookieSink) context.Context {
|
||||
if sink == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, cookieSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// cookieSinkFrom 取出请求 context 上的 sink(没有则 nil)。
|
||||
func cookieSinkFrom(ctx context.Context) *cookieSink {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
sink, _ := ctx.Value(cookieSinkKey{}).(*cookieSink)
|
||||
return sink
|
||||
}
|
||||
|
||||
// captureRedirectCookies 作为 http.Client.CheckRedirect:
|
||||
// 每发生一次跳转,就把「上一跳响应」里的 Set-Cookie 收进 context 上的 sink。
|
||||
//
|
||||
// 没有挂 sink 时是纯透传;跳转次数上限与 net/http 的默认实现保持一致
|
||||
// (10 次),所以装上它不改变既有链路的行为。
|
||||
func captureRedirectCookies(req *http.Request, via []*http.Request) error {
|
||||
if req != nil {
|
||||
if sink := cookieSinkFrom(req.Context()); sink != nil && req.Response != nil {
|
||||
// via 的最后一跳才是产生 req.Response 的那个请求。
|
||||
prev := req
|
||||
if n := len(via); n > 0 {
|
||||
prev = via[n-1]
|
||||
}
|
||||
sink.add(prev.URL, req.Response)
|
||||
}
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// enableCookieCapture 给 reader 的 HTTP 客户端装上「逐跳收集 Set-Cookie」。
|
||||
// NewReaderService 调用一次即可:没有挂 sink 的请求完全不受影响。
|
||||
func enableCookieCapture(c *http.Client) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.CheckRedirect = captureRedirectCookies
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 书架分组(对应 legado BookGroup;存储见 model.ReaderBookGroups)。
|
||||
//
|
||||
// 语义与影视模块的媒体库标签一致:整份替换、一本书只归一个组、组内顺序即展示顺序。
|
||||
// 额外的两条收敛放在服务层,因为它们需要知道书籍是否存在:
|
||||
// - 只保留该用户书架上的书(书被移出书架后分组里不留死 ID);
|
||||
// - 一本书只归一个组(越靠前的分组优先)。
|
||||
// 空分组会保留:用户可能就是先建好分组再往里放书。
|
||||
|
||||
// GetBookGroups 读用户的书架分组。没有分组时返回空切片(非 nil),方便前端直接遍历。
|
||||
func (s *ReaderService) GetBookGroups(ctx context.Context, userID string) ([]model.BookGroupSet, error) {
|
||||
if userID == "" {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
row, err := s.repo.GetBookGroups(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups := model.NormalizeBookGroups(row.DecodeBookGroups())
|
||||
if len(groups) == 0 {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
owned, err := s.ownedBookIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterBookGroups(groups, owned), nil
|
||||
}
|
||||
|
||||
// SetBookGroups 覆盖保存用户的书架分组,落库前做过收敛与归属过滤。
|
||||
func (s *ReaderService) SetBookGroups(ctx context.Context, userID string, groups []model.BookGroupSet) ([]model.BookGroupSet, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("缺少用户信息")
|
||||
}
|
||||
owned, err := s.ownedBookIDs(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := dedupeBookGroups(filterBookGroups(model.NormalizeBookGroups(groups), owned))
|
||||
raw, err := model.EncodeBookGroups(normalized)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repo.SaveBookGroups(ctx, &model.ReaderBookGroups{UserID: userID, Groups: raw}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return []model.BookGroupSet{}, nil
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// ownedBookIDs 返回该用户书架上的书籍 ID 集合。
|
||||
func (s *ReaderService) ownedBookIDs(ctx context.Context, userID string) (map[string]struct{}, error) {
|
||||
books, err := s.repo.ListBooks(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]struct{}, len(books))
|
||||
for i := range books {
|
||||
out[books[i].ID] = struct{}{}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// filterBookGroups 只保留 owned 里的书籍 ID;空分组原样保留。
|
||||
func filterBookGroups(groups []model.BookGroupSet, owned map[string]struct{}) []model.BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]model.BookGroupSet, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
ids := make([]string, 0, len(group.BookIDs))
|
||||
for _, id := range group.BookIDs {
|
||||
if _, ok := owned[id]; ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dedupeBookGroups 收敛成「一本书只归一个组」:越靠前的分组优先,后面的重复项被移除。
|
||||
func dedupeBookGroups(groups []model.BookGroupSet) []model.BookGroupSet {
|
||||
if len(groups) == 0 {
|
||||
return nil
|
||||
}
|
||||
claimed := make(map[string]struct{})
|
||||
out := make([]model.BookGroupSet, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
ids := make([]string, 0, len(group.BookIDs))
|
||||
for _, id := range group.BookIDs {
|
||||
if _, ok := claimed[id]; ok {
|
||||
continue
|
||||
}
|
||||
claimed[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
out = append(out, model.BookGroupSet{Name: group.Name, BookIDs: ids})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -501,6 +501,11 @@ func (s *ReaderService) LocalChapterContent(
|
||||
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
|
||||
text = s.rewriteLocalImages(book.ID, text)
|
||||
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
|
||||
// 图片版 EPUB(整章都是 [img] 标记)同样是漫画:按图片类型下发,
|
||||
// 前端走漫画阅读器,才有整页缩放与双页铺开。
|
||||
if imgs, ok := imageMarkersOnly(text); ok {
|
||||
return &ChapterContent{Type: "image", Images: imgs, declaredType: -1}, nil
|
||||
}
|
||||
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
|
||||
|
||||
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("未登录时不应搜到结果: %+v", books)
|
||||
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
// ── 登录后搜索:应携带 Cookie 并成功 ──
|
||||
books, skipped, err := svc.Search(ctx, "会员")
|
||||
books, skipped, err := svc.Search(ctx, "会员", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
|
||||
if info.LoggedIn || len(info.Cookies) != 0 {
|
||||
t.Fatalf("登出后不应残留登录态: %+v", info)
|
||||
}
|
||||
if books, _, err := svc.Search(ctx, "会员"); err != nil {
|
||||
if books, _, err := svc.Search(ctx, "会员", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if len(books) != 0 {
|
||||
t.Fatalf("登出后不应还能搜到结果: %+v", books)
|
||||
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
}`
|
||||
sourceID := prepareLoginSource(t, svc, srcJSON)
|
||||
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 自动捕获被关闭:不应出现 auto=from-response
|
||||
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
|
||||
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := svc.Search(ctx, "任意"); err != nil {
|
||||
if _, _, err := svc.Search(ctx, "任意", nil, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 阅读器偏好(每个用户一条,见 model.ReaderProfile)。
|
||||
//
|
||||
// 这些值原本只存在浏览器 localStorage,属于设备级;现在按用户落库,让主题、
|
||||
// 排版、听书、书架展示设置能跨设备一致。服务端只做「存储 + 收敛」,
|
||||
// 具体语义(哪些主题、怎么排版)仍由前端决定,所以这里只校验取值范围与枚举,
|
||||
// 不解释它们。
|
||||
|
||||
// 取值范围与前端 readerSettings store 里的 clamp 完全一致,两边改动需同步。
|
||||
const (
|
||||
readerFontSizeMin, readerFontSizeMax = 14, 32
|
||||
readerLineHeightMin, readerLineHeightMax = 1.4, 2.6
|
||||
readerParaSpacingMin, readerParaSpacingMax = 0, 32
|
||||
readerAudioSpeedMin, readerAudioSpeedMax = 0.5, 3
|
||||
readerAudioTimerMin, readerAudioTimerMax = 0, 180
|
||||
readerGridColumnsMin, readerGridColumnsMax = 0, 6
|
||||
|
||||
// readerThemeIDDefault 主题标识的自愈值:themeId 不枚举校验(前端可以自由
|
||||
// 新增主题),只限制长度,空值时回落到内置的「预设1」。
|
||||
readerThemeIDDefault = "preset1"
|
||||
readerThemeIDMaxLen = 32
|
||||
)
|
||||
|
||||
// 前端定义的枚举取值;未知值一律回落成默认项,避免脏值把阅读器置于不可用状态。
|
||||
var (
|
||||
readerPageModes = []string{"page", "scroll"}
|
||||
readerShelfLayout = []string{"grid", "list", "compact"}
|
||||
readerShelfSorts = []string{"recent", "update", "mixed", "name", "author", "manual"}
|
||||
)
|
||||
|
||||
// ReaderSettings 阅读器偏好的读写载荷(与前端 ReaderSettingsProfile 字段一一对应)。
|
||||
type ReaderSettings struct {
|
||||
ThemeID string `json:"theme_id"`
|
||||
Night bool `json:"night"`
|
||||
PageMode string `json:"page_mode"`
|
||||
FontSize int `json:"font_size"`
|
||||
LineHeight float64 `json:"line_height"`
|
||||
ParagraphSpacing int `json:"paragraph_spacing"`
|
||||
|
||||
AudioSpeed float64 `json:"audio_speed"`
|
||||
AudioTimerMinutes int `json:"audio_timer_minutes"`
|
||||
|
||||
ShelfLayout string `json:"shelf_layout"`
|
||||
ShelfGridColumns int `json:"shelf_grid_columns"`
|
||||
ShelfSort string `json:"shelf_sort"`
|
||||
ShelfShowUnread bool `json:"shelf_show_unread"`
|
||||
ShelfShowUpdateTime bool `json:"shelf_show_update_time"`
|
||||
}
|
||||
|
||||
// GetReaderSettings 读用户阅读器偏好。
|
||||
// 该用户还没保存过时返回 (nil, nil):由前端用它本地的值播种,避免这里再维护一份默认值。
|
||||
func (s *ReaderService) GetReaderSettings(ctx context.Context, userID string) (*ReaderSettings, error) {
|
||||
if userID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row, err := s.repo.GetReaderProfile(ctx, userID)
|
||||
if err != nil || row == nil {
|
||||
return nil, err
|
||||
}
|
||||
out := settingsFromProfile(*row)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// SaveReaderSettings 覆盖保存用户阅读器偏好,落库前先做范围收敛。
|
||||
func (s *ReaderService) SaveReaderSettings(ctx context.Context, userID string, in ReaderSettings) (*ReaderSettings, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("缺少用户信息")
|
||||
}
|
||||
sanitized := sanitizeReaderSettings(in)
|
||||
row := &model.ReaderProfile{UserID: userID}
|
||||
applySettingsToProfile(row, sanitized)
|
||||
if err := s.repo.SaveReaderProfile(ctx, row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := settingsFromProfile(*row)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// sanitizeReaderSettings 把入参收敛到合法范围:数值夹到区间、枚举未知即回落默认、
|
||||
// 主题标识去空白并限长。
|
||||
func sanitizeReaderSettings(in ReaderSettings) ReaderSettings {
|
||||
return ReaderSettings{
|
||||
ThemeID: sanitizeThemeID(in.ThemeID),
|
||||
Night: in.Night,
|
||||
PageMode: oneOf(in.PageMode, readerPageModes, readerPageModes[0]),
|
||||
FontSize: clampInt(in.FontSize, readerFontSizeMin, readerFontSizeMax),
|
||||
LineHeight: roundTo1(clampFloat(in.LineHeight, readerLineHeightMin, readerLineHeightMax)),
|
||||
ParagraphSpacing: clampInt(in.ParagraphSpacing, readerParaSpacingMin, readerParaSpacingMax),
|
||||
|
||||
AudioSpeed: roundTo1(clampFloat(in.AudioSpeed, readerAudioSpeedMin, readerAudioSpeedMax)),
|
||||
AudioTimerMinutes: clampInt(in.AudioTimerMinutes, readerAudioTimerMin, readerAudioTimerMax),
|
||||
|
||||
ShelfLayout: oneOf(in.ShelfLayout, readerShelfLayout, readerShelfLayout[0]),
|
||||
ShelfGridColumns: clampInt(in.ShelfGridColumns, readerGridColumnsMin, readerGridColumnsMax),
|
||||
ShelfSort: oneOf(in.ShelfSort, readerShelfSorts, readerShelfSorts[0]),
|
||||
ShelfShowUnread: in.ShelfShowUnread,
|
||||
ShelfShowUpdateTime: in.ShelfShowUpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeThemeID(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || len(v) > readerThemeIDMaxLen {
|
||||
return readerThemeIDDefault
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func settingsFromProfile(p model.ReaderProfile) ReaderSettings {
|
||||
return ReaderSettings{
|
||||
ThemeID: p.ThemeID,
|
||||
Night: p.Night,
|
||||
PageMode: p.PageMode,
|
||||
FontSize: p.FontSize,
|
||||
LineHeight: p.LineHeight,
|
||||
ParagraphSpacing: p.ParagraphSpacing,
|
||||
|
||||
AudioSpeed: p.AudioSpeed,
|
||||
AudioTimerMinutes: p.AudioTimerMinutes,
|
||||
|
||||
ShelfLayout: p.ShelfLayout,
|
||||
ShelfGridColumns: p.ShelfGridColumns,
|
||||
ShelfSort: p.ShelfSort,
|
||||
ShelfShowUnread: p.ShelfShowUnread,
|
||||
ShelfShowUpdateTime: p.ShelfShowUpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
func applySettingsToProfile(p *model.ReaderProfile, in ReaderSettings) {
|
||||
p.ThemeID = in.ThemeID
|
||||
p.Night = in.Night
|
||||
p.PageMode = in.PageMode
|
||||
p.FontSize = in.FontSize
|
||||
p.LineHeight = in.LineHeight
|
||||
p.ParagraphSpacing = in.ParagraphSpacing
|
||||
|
||||
p.AudioSpeed = in.AudioSpeed
|
||||
p.AudioTimerMinutes = in.AudioTimerMinutes
|
||||
|
||||
p.ShelfLayout = in.ShelfLayout
|
||||
p.ShelfGridColumns = in.ShelfGridColumns
|
||||
p.ShelfSort = in.ShelfSort
|
||||
p.ShelfShowUnread = in.ShelfShowUnread
|
||||
p.ShelfShowUpdateTime = in.ShelfShowUpdateTime
|
||||
}
|
||||
|
||||
func clampInt(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func clampFloat(v, lo, hi float64) float64 {
|
||||
// JSON 不会给出 NaN,这里是防御性的(例如将来接了别的调用方)
|
||||
if math.IsNaN(v) || v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func roundTo1(v float64) float64 {
|
||||
return math.Round(v*10) / 10
|
||||
}
|
||||
|
||||
// oneOf 返回 v(当它属于 allowed 时),否则返回 fallback。
|
||||
func oneOf(v string, allowed []string, fallback string) string {
|
||||
for _, a := range allowed {
|
||||
if v == a {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestProxyURLSignRoundTrip 验证媒体代理签名:往返还原 + 防篡改。
|
||||
@@ -41,3 +47,88 @@ func TestProxyURLSignRoundTrip(t *testing.T) {
|
||||
t.Fatalf("double wrap: %q", again)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceReferer 验证默认 Referer 只在 origin 是真正的 http(s) 书源地址时
|
||||
// 才生成。聚合类书源的 origin 是显示名(如「光遇聚合」),拿它当 Referer 会被
|
||||
// 图床判盗链,返回一张「请到本网站阅读」的占位图。
|
||||
func TestSourceReferer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
origin string
|
||||
want string
|
||||
}{
|
||||
{"普通书源地址", "https://www.example.com", "https://www.example.com/"},
|
||||
{"带尾斜杠", "https://www.example.com/", "https://www.example.com/"},
|
||||
{"带子路径", "https://www.example.com/site", "https://www.example.com/site/"},
|
||||
{"聚合书源显示名", "光遇聚合", ""},
|
||||
{"空值", "", ""},
|
||||
{"只有空白", " ", ""},
|
||||
{"非 http 协议", "ftp://example.com", ""},
|
||||
{"没有主机", "https://", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sourceReferer(c.origin); got != c.want {
|
||||
t.Errorf("%s: sourceReferer(%q) = %q,期望 %q", c.name, c.origin, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaRetriesTransientFailure 验证图床偶发 403(并发限流)会被重试掉,
|
||||
// 否则漫画一屏并发取图时总有几页留成破图。
|
||||
func TestFetchMediaRetriesTransientFailure(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if atomic.AddInt32(&hits, 1) < 3 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/webp")
|
||||
_, _ = w.Write([]byte("real-image-bytes"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/1.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("状态码 = %d,期望 200(前两次 403 应被重试掉)", resp.StatusCode)
|
||||
}
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
if string(data) != "real-image-bytes" {
|
||||
t.Fatalf("body = %q", data)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 3 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchMediaDoesNotRetryNotFound 验证确定性的 4xx(如图片不存在)不做无谓重试,
|
||||
// 避免首屏白白多等两轮退避。
|
||||
func TestFetchMediaDoesNotRetryNotFound(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := &ReaderService{http: &http.Client{Timeout: 10 * time.Second}}
|
||||
book := &model.ReaderBook{Origin: srv.URL}
|
||||
|
||||
resp, err := s.FetchMedia(t.Context(), book, srv.URL+"/missing.webp", "")
|
||||
if err != nil {
|
||||
t.Fatalf("FetchMedia 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("状态码 = %d,期望 404", resp.StatusCode)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("上游请求次数 = %d,期望 1(404 不该重试)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sourceRateLimiter 单源限速。
|
||||
//
|
||||
// 书源用 concurrentRate 声明「请求该源的最小间隔」,风控严格的站点靠它避免被封
|
||||
// (不实现的话,搜索/目录/正文连着打过去很容易被拦,表现为「有时能用有时不能用」)。
|
||||
// 对应 legado 的 ConcurrentRateLimiter,支持两种写法:
|
||||
//
|
||||
// "1000" 每次请求至少间隔 1000ms
|
||||
// "3/1000" 每 1000ms 最多 3 次(折算成 333ms 间隔)
|
||||
//
|
||||
// 实现为「按源排槽位」:同一书源的请求被排成固定间隔,天然串行。
|
||||
type sourceRateLimiter struct {
|
||||
mu sync.Mutex
|
||||
slots map[string]time.Time
|
||||
}
|
||||
|
||||
// maxRateGapMs 允许的最大间隔,避免书源写出离谱的值把请求卡死。
|
||||
const maxRateGapMs = 30000
|
||||
|
||||
func newSourceRateLimiter() *sourceRateLimiter {
|
||||
return &sourceRateLimiter{slots: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// wait 按源限速等待;key 为空或 rate 无法解析时不等待。
|
||||
func (l *sourceRateLimiter) wait(ctx context.Context, key, rate string) {
|
||||
gap := parseConcurrentRate(rate)
|
||||
if gap <= 0 || key == "" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
slot := now
|
||||
if next, ok := l.slots[key]; ok && next.After(now) {
|
||||
slot = next
|
||||
}
|
||||
l.slots[key] = slot.Add(gap)
|
||||
l.mu.Unlock()
|
||||
sleepWithContext(ctx, slot.Sub(now))
|
||||
}
|
||||
|
||||
// parseConcurrentRate 解析 concurrentRate,返回两次请求的最小间隔;无法解析返回 0。
|
||||
func parseConcurrentRate(rate string) time.Duration {
|
||||
rate = strings.TrimSpace(rate)
|
||||
if rate == "" {
|
||||
return 0
|
||||
}
|
||||
gapMs := 0
|
||||
if i := strings.IndexByte(rate, '/'); i > 0 {
|
||||
count, errCount := strconv.Atoi(strings.TrimSpace(rate[:i]))
|
||||
interval, errInterval := strconv.Atoi(strings.TrimSpace(rate[i+1:]))
|
||||
if errCount != nil || errInterval != nil || count <= 0 || interval <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = interval / count
|
||||
} else {
|
||||
n, err := strconv.Atoi(rate)
|
||||
if err != nil || n <= 0 {
|
||||
return 0
|
||||
}
|
||||
gapMs = n
|
||||
}
|
||||
if gapMs <= 0 {
|
||||
return 0
|
||||
}
|
||||
if gapMs > maxRateGapMs {
|
||||
gapMs = maxRateGapMs
|
||||
}
|
||||
return time.Duration(gapMs) * time.Millisecond
|
||||
}
|
||||
|
||||
const (
|
||||
// maxRequestAttempts 单次请求最大尝试次数(含首次),兜住书源里写很大的 retry。
|
||||
maxRequestAttempts = 5
|
||||
// requestRetryDelay 请求重试退避基数,实际等待为 attempt 倍。
|
||||
requestRetryDelay = 300 * time.Millisecond
|
||||
)
|
||||
|
||||
// retryableStatus 该状态码是否值得重试:限流(403/429)与上游抖动(5xx)可重试,
|
||||
// 404/400 这类确定性错误不重试。规则请求与媒体代理共用。
|
||||
func retryableStatus(code int) bool {
|
||||
switch code {
|
||||
case 403, 408, 425, 429, 500, 502, 503, 504:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// sleepWithContext 可被 ctx 取消的等待;返回 false 表示上下文已结束。
|
||||
func sleepWithContext(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestParseConcurrentRate 书源 concurrentRate 的两种写法。
|
||||
func TestParseConcurrentRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want time.Duration
|
||||
}{
|
||||
{"", 0},
|
||||
{"0", 0},
|
||||
{"abc", 0},
|
||||
{"1000", 1000 * time.Millisecond},
|
||||
{" 500 ", 500 * time.Millisecond},
|
||||
{"3/1000", 333 * time.Millisecond},
|
||||
{"1/2000", 2000 * time.Millisecond},
|
||||
{"3/0", 0},
|
||||
{"0/1000", 0},
|
||||
{"999999", time.Duration(maxRateGapMs) * time.Millisecond}, // 上限夹紧
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseConcurrentRate(c.in); got != c.want {
|
||||
t.Errorf("parseConcurrentRate(%q) = %v,期望 %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterSpacesRequests 同一个源连续两次请求之间至少间隔一个周期。
|
||||
func TestRateLimiterSpacesRequests(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
l.wait(t.Context(), "src", "120") // 120ms
|
||||
l.wait(t.Context(), "src", "120")
|
||||
if elapsed := time.Since(start); elapsed < 110*time.Millisecond {
|
||||
t.Fatalf("两次请求只隔了 %v,没有按 concurrentRate 限速", elapsed)
|
||||
}
|
||||
// 不同源互不影响
|
||||
start = time.Now()
|
||||
l.wait(t.Context(), "other", "500")
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("换源后不该等待,实际 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRateLimiterIgnoresEmptyRate 未声明 concurrentRate 时不引入任何等待。
|
||||
func TestRateLimiterIgnoresEmptyRate(t *testing.T) {
|
||||
l := newSourceRateLimiter()
|
||||
start := time.Now()
|
||||
for i := 0; i < 5; i++ {
|
||||
l.wait(t.Context(), "src", "")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
|
||||
t.Fatalf("未声明限速却等待了 %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRetryableStatus 只有可恢复的失败才重试。
|
||||
func TestRetryableStatus(t *testing.T) {
|
||||
for _, code := range []int{403, 429, 500, 502, 503, 504} {
|
||||
if !retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 应当可重试", code)
|
||||
}
|
||||
}
|
||||
for _, code := range []int{200, 301, 400, 401, 404, 410} {
|
||||
if retryableStatus(code) {
|
||||
t.Errorf("状态码 %d 不该重试", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1000
-135
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:拷贝系列轻小说源的端到端回归。
|
||||
//
|
||||
// 合成一个「照搬拷贝轻小说写法」的书源,一次覆盖四个修复点:
|
||||
// 1. 搜索列表规则 `results.list.[*]`(legado 的 `.[*]` 形式);
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()/ArrayList()` 拼装;
|
||||
// 3. 正文规则用 `java.cacheFile(url)` 取整卷文本;
|
||||
// 4. 正文是 GBK 文本——必须走 Raw 原始字节通道落盘,否则会被 UTF-8 解码成乱码。
|
||||
|
||||
func TestCopyLightNovelSourceEndToEnd(t *testing.T) {
|
||||
var srv *httptest.Server
|
||||
gbkBody, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("第一章 开始\n第二章 继续"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
// 搜索:Django REST 风格的 JSON。q_type/_update 参数照抄真实源。
|
||||
mux.HandleFunc("/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[
|
||||
{"name":"转生成为史莱姆","path_word":"slime","author":[{"name":"作者甲"}],"cover":"https://img.example.com/1.jpg"}
|
||||
]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/book/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"book":{"name":"转生成为史莱姆","path_word":"slime"}}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volumes", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"code":200,"results":{"list":[{"name":"第一卷","id":7}]}}`))
|
||||
})
|
||||
mux.HandleFunc("/api/volume/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"code":200,"results":{"volume":{"txt_addr":"%s/api/txt/7","txt_encoding":"GBK"}}}`, srv.URL)
|
||||
})
|
||||
mux.HandleFunc("/api/txt/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = w.Write(gbkBody)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
source := map[string]any{
|
||||
"bookSourceName": "合成拷贝轻小说源",
|
||||
"bookSourceUrl": srv.URL,
|
||||
"enabled": true,
|
||||
"enabledExplore": true,
|
||||
"searchUrl": srv.URL + "/api/search?limit=20&q_type=&q={{key}}&_update=true",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "results.list.[*]",
|
||||
"name": "$.name",
|
||||
"author": "$.author[*].name",
|
||||
"coverUrl": "$.cover",
|
||||
"bookUrl": srv.URL + "/api/book/{{$.path_word}}?_update=true",
|
||||
},
|
||||
"ruleBookInfo": map[string]any{
|
||||
"init": "$.results.book",
|
||||
"name": "$.name",
|
||||
"intro": "$.brief",
|
||||
"tocUrl": srv.URL + "/api/volumes?book={{$.path_word}}&_update=true",
|
||||
},
|
||||
"ruleToc": map[string]any{
|
||||
"chapterList": `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = '` + srv.URL + `/api/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id + '?_update=true')); }
|
||||
out;</js>`,
|
||||
"chapterName": "$.name",
|
||||
"chapterUrl": "$.url",
|
||||
},
|
||||
"ruleContent": map[string]any{
|
||||
"content": `@js:java.cacheFile(java.getString('$..txt_addr'))`,
|
||||
},
|
||||
}
|
||||
raw, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
svc.cfg.Cache.CacheDir = t.TempDir()
|
||||
|
||||
res := svc.SmokeSource(t.Context(), string(raw), "史莱姆")
|
||||
if !res.OK {
|
||||
t.Fatalf("链路失败于 %s: %s\n%s", res.FailedAt, res.Error, joinLogs(res))
|
||||
}
|
||||
if res.SearchHits != 1 {
|
||||
t.Fatalf("搜索命中=%d, want 1(`.[*]` 路径修复)", res.SearchHits)
|
||||
}
|
||||
if res.Chapters != 1 {
|
||||
t.Fatalf("章节数=%d, want 1(Packages 修复)", res.Chapters)
|
||||
}
|
||||
if res.ContentLen == 0 {
|
||||
t.Fatalf("正文长度=0(cacheFile/Raw 修复未生效)\n%s", joinLogs(res))
|
||||
}
|
||||
if !strings.Contains(joinLogs(res), "第一章") {
|
||||
t.Fatalf("正文未正确解码(GBK 落盘被 utf-8 破坏)\n%s", joinLogs(res))
|
||||
}
|
||||
}
|
||||
|
||||
func joinLogs(res *SmokeChainResult) string {
|
||||
var b strings.Builder
|
||||
for _, l := range res.Logs {
|
||||
fmt.Fprintf(&b, "[%s/%s] %s\n", l.Stage, l.Level, l.Message)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestRewriteContentImageMarkers 文本型漫画源正文里的 <img> 要转成前端认识的 [img] 标记,
|
||||
// 且地址要绝对化、过签名代理(有些图需要书源防盗链头才取得到)。
|
||||
func TestRewriteContentImageMarkers(t *testing.T) {
|
||||
proxy := func(u string) string { return "/proxy?u=" + url.QueryEscape(u) }
|
||||
content := "第一章开始\n" +
|
||||
"<img src=\"https://img.example.com/1.webp\">\n" +
|
||||
"<img src='/img/2.webp'/>\n" +
|
||||
"中间夹了图片 <img src=\"https://img.example.com/3.webp\"> 的这一行不动\n" +
|
||||
"结束"
|
||||
got := rewriteContentImageMarkers(content, "https://site.example.com/ch/1", proxy)
|
||||
lines := strings.Split(got, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("行数 = %d,期望 5\n%s", len(lines), got)
|
||||
}
|
||||
if lines[0] != "第一章开始" || lines[4] != "结束" {
|
||||
t.Fatalf("普通文本行被改动: %q / %q", lines[0], lines[4])
|
||||
}
|
||||
if !strings.HasPrefix(lines[1], imgMarkerPrefix) {
|
||||
t.Fatalf("第 2 行没有变成 [img] 标记: %q", lines[1])
|
||||
}
|
||||
if !strings.Contains(lines[1], url.QueryEscape("https://img.example.com/1.webp")) {
|
||||
t.Fatalf("第 2 行地址没走代理: %q", lines[1])
|
||||
}
|
||||
// 相对地址按章节地址绝对化
|
||||
if !strings.Contains(lines[2], url.QueryEscape("https://site.example.com/img/2.webp")) {
|
||||
t.Fatalf("相对图片地址没有绝对化: %q", lines[2])
|
||||
}
|
||||
// 段落中间的内嵌图片保持原样,避免破坏文本源语义
|
||||
if !strings.Contains(lines[3], "<img src=") {
|
||||
t.Fatalf("段落中间的 <img> 不该被改写: %q", lines[3])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRewriteContentImageMarkersWithoutProxy 不需要代理时(如预览链路)直接落到绝对地址。
|
||||
func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
|
||||
got := rewriteContentImageMarkers(`<img src="https://img.example.com/1.webp">`, "https://site.example.com/ch/1", nil)
|
||||
if got != imgMarkerPrefix+"https://img.example.com/1.webp" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
// 没有 <img> 的内容原样返回(含 null 字节之外的普通文本)
|
||||
const plain = "纯文本\n第二行"
|
||||
if got := rewriteContentImageMarkers(plain, "", nil); got != plain {
|
||||
t.Fatalf("无图内容被改动: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImageMarkersOnly 整章都是 [img] 标记时判定为图片章(前端才能走漫画阅读器、
|
||||
// 双页铺开);只要掺了能读的正文就必须保持文本,不能把文字吃掉。
|
||||
func TestImageMarkersOnly(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
wantOK bool
|
||||
wantURLs []string
|
||||
}{
|
||||
{
|
||||
name: "整章都是标记",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "夹着空行 / 孤立 html 标签 / 首尾空白",
|
||||
content: "\n" + imgMarkerPrefix + "/a.jpg\n \n<div>\n</div>\n< br >\n" +
|
||||
" " + imgMarkerPrefix + " /b.jpg \n",
|
||||
wantOK: true,
|
||||
wantURLs: []string{"/a.jpg", "/b.jpg"},
|
||||
},
|
||||
{
|
||||
name: "掺了正文就保持文本(图 + 长段落)",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + "第一句话。\n" + imgMarkerPrefix + "/b.jpg\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "纯文本",
|
||||
content: "第一章\n正文正文\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "空正文",
|
||||
content: "\n\n \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "标记地址为空",
|
||||
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + " \n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 「<3」这类不是标签,是有内容的一行,不能当成空白忽略
|
||||
name: "非标签的尖括号行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
// 回归:<p>正文</p> 是「标签里裹着正文」,绝不能当成孤立标签跳过,
|
||||
// 否则整章正文会被误判成图片章清空。
|
||||
name: "标签里裹着正文的行",
|
||||
content: imgMarkerPrefix + "/a.jpg\n<p>第一章正文</p>\n<p>第二章正文</p>\n",
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := imageMarkersOnly(c.content)
|
||||
if ok != c.wantOK {
|
||||
t.Errorf("%s: ok = %v,期望 %v(urls=%v)", c.name, ok, c.wantOK, got)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if len(got) != len(c.wantURLs) {
|
||||
t.Errorf("%s: urls = %v,期望 %v", c.name, got, c.wantURLs)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.wantURLs[i] {
|
||||
t.Errorf("%s: urls[%d] = %q,期望 %q", c.name, i, got[i], c.wantURLs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsHTMLTagOnly 「没有可读文字的标签行」才算空行:标签里裹着正文的行必须放行,
|
||||
// 否则 imageMarkersOnly 会把整章正文当图片丢掉。
|
||||
func TestIsHTMLTagOnly(t *testing.T) {
|
||||
blank := []string{"<div>", "</div>", "< br >", "<br/>", "<p></p>", "<hr />", "</p >", "<a> </a>", "<p>"}
|
||||
for _, s := range blank {
|
||||
if !isHTMLTagOnly(s) {
|
||||
t.Errorf("%q 应视为没有可读文字的标签行", s)
|
||||
}
|
||||
}
|
||||
text := []string{
|
||||
"<p>正文</p>", "<p>正文</p></p>", "<div>文字</div>", "<p>第一段</p><p>第二段</p>",
|
||||
"正文 <b>粗</b>", "3 < 5", "<3>", "a", "", " ",
|
||||
}
|
||||
for _, s := range text {
|
||||
if isHTMLTagOnly(s) {
|
||||
t.Errorf("%q 含可读文字/不是标签,不能当空行", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeContentBlocks 块级标签折成换行、行内标签与文字原样保留。
|
||||
func TestNormalizeContentBlocks(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"<p>第一段</p>\n<p>第二段</p>", "第一段\n第二段"},
|
||||
{"<p>第一段<br>折行文字</p>", "第一段\n折行文字"},
|
||||
{"<P CLASS='x'>大写带属性</P>", "大写带属性"},
|
||||
{"<div>块</div><br/><br />", "块"},
|
||||
// 行内插图原样保留(不能吃掉普通 <img>)
|
||||
{`<p>正文<img src="https://cdn.example.com/a.jpg"></p>`, `正文<img src="https://cdn.example.com/a.jpg">`},
|
||||
// 纯文本原样返回
|
||||
{"第一段。\n第二段。", "第一段。\n第二段。"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeContentBlocks(c.in); got != c.want {
|
||||
t.Errorf("normalizeContentBlocks(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestChapterContentEmpty 判断「什么都没取到」的正文。
|
||||
//
|
||||
// 聚合类书源把所有线路试完会返回空串(光遇聚合的 request() 就是这么写的),
|
||||
// 这种空结果不能再当成功下发(前端会渲染成白页并缓存下来)。
|
||||
func TestChapterContentEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in ChapterContent
|
||||
want bool
|
||||
}{
|
||||
{"空正文", ChapterContent{Type: "text"}, true},
|
||||
{"只有空白字符", ChapterContent{Type: "text", Content: " \n\t "}, true},
|
||||
{"正常正文", ChapterContent{Type: "text", Content: "第一章 世界大变"}, false},
|
||||
// 整章正文只有一个本章说气泡时正文文字为空,但不能算抓取失败。
|
||||
{"只有段评气泡", ChapterContent{Type: "text", Comments: []ContentComment{{Line: 0, Count: 3}}}, false},
|
||||
{"音频无音轨", ChapterContent{Type: "audio"}, true},
|
||||
{"音频有音轨", ChapterContent{Type: "audio", Tracks: []string{"https://cdn.example.com/a.m4a"}}, false},
|
||||
{"漫画无图", ChapterContent{Type: "image"}, true},
|
||||
{"漫画有图", ChapterContent{Type: "image", Images: []string{"https://cdn.example.com/1.jpg"}}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := chapterContentEmpty(&c.in); got != c.want {
|
||||
t.Errorf("%s: chapterContentEmpty = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
|
||||
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
|
||||
func TestSearchCheckKeyWord(t *testing.T) {
|
||||
mk := func(kw string) *BookSource {
|
||||
return &BookSource{RuleSearch: &SearchRule{CheckKeyWord: &kw}}
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"登录武林系统", "登录武林系统"},
|
||||
{" 空格清理 ", "空格清理"},
|
||||
{"", ""},
|
||||
{"http://example.com", ""},
|
||||
{"a::b", ""},
|
||||
{"a++b", ""},
|
||||
{"a--b", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := mk(c.in).SearchCheckKeyWord(); got != c.want {
|
||||
t.Errorf("SearchCheckKeyWord(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
if (&BookSource{}).SearchCheckKeyWord() != "" {
|
||||
t.Fatal("没有 ruleSearch 时应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchReportsCheckKeyWordMiss 搜索 0 条且响应里没有书源声明的校验关键字时,
|
||||
// 必须给出「疑似风控」的原因,而不是静默返回 0 条 —— 用户才知道是源的问题。
|
||||
func TestSearchReportsCheckKeyWordMiss(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 风控/失效时站点返回的就是这种没有结果的响应
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err == nil {
|
||||
t.Fatal("0 结果且校验关键字缺失时应返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), kw) {
|
||||
t.Fatalf("错误信息里应带上校验关键字,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchKeepsEmptyResultWhenKeywordPresent 响应里有关键字(站点正常,只是没有匹配的书)
|
||||
// 时不该报错,只是结果为空。
|
||||
func TestSearchKeepsEmptyResultWhenKeywordPresent(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"code":200,"hint":"登录武林系统","results":{"list":[]}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
kw := "登录武林系统"
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "校验关键字测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
SearchURL: strPtr(srv.URL + "/search?q={{key}}"),
|
||||
RuleSearch: &SearchRule{
|
||||
BookList: strPtr("$.results.list[*]"),
|
||||
Name: strPtr("$.name"),
|
||||
BookURL: strPtr("$.url"),
|
||||
CheckKeyWord: &kw,
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
books, err := svc.searchInSource(t.Context(), src, bs, "火影", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("站点正常时不该报错: %v", err)
|
||||
}
|
||||
if len(books) != 0 {
|
||||
t.Fatalf("结果数 = %d,期望 0", len(books))
|
||||
}
|
||||
}
|
||||
@@ -213,6 +213,22 @@ func TestEndToEndSourceChain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetContentForBookEmptyContentFails 书源一条内容都没返回时不能当成功下发。
|
||||
//
|
||||
// 回归:聚合类书源把七条线路全试完会返回空串(光遇聚合的 request() 就是这么写的),
|
||||
// 以前这种空正文会当成功下发,前端渲染成一张白页并缓存下来,读者只能干等。
|
||||
func TestGetContentForBookEmptyContentFails(t *testing.T) {
|
||||
svc, bookID := setupTextChapterBook(t, "")
|
||||
|
||||
_, err := svc.GetContentForBook(t.Context(), "u1", bookID, 0)
|
||||
if err == nil {
|
||||
t.Fatal("空正文应当报错,实际当成功下发了")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "正文为空") {
|
||||
t.Fatalf("错误信息 = %q,期望提示正文为空", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 纯函数测试:导入识别 / 搜索合并 ────────────────────────────────────────
|
||||
|
||||
func TestParseSourcePayload(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 本文件:「更新目录」的结果分档回归测试。
|
||||
//
|
||||
// 背景:书架提示曾经把「抓到了目录但没有新章节」也算成失败。完结书每次检查都没有新章节,
|
||||
// 于是书架天天报「N 本失败」,看起来像书源坏了。现在必须区分三档:
|
||||
// 抓到新章节 / 抓到但没变(已是最新)/ 抓取或写入失败。
|
||||
|
||||
// refreshTocForTest 跑一次「更新目录」并返回汇总。
|
||||
func refreshTocForTest(t *testing.T, svc *ReaderService, ctx context.Context) *TocRefreshResult {
|
||||
t.Helper()
|
||||
res, err := svc.RefreshBooksToc(ctx, "u1")
|
||||
if err != nil {
|
||||
t.Fatalf("更新目录失败: %v", err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// newRefreshTocServer 建一个目录页内容可切换的测试源站点。
|
||||
func newRefreshTocServer(tocHandler *http.HandlerFunc) *httptest.Server {
|
||||
return 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"):
|
||||
(*tocHandler)(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestRefreshBooksTocSeparatesUnchangedFromFailed(t *testing.T) {
|
||||
tocHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
})
|
||||
srv := newRefreshTocServer(&tocHandler)
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/1",
|
||||
TocURL: srv.URL + "/book/1/toc.html",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(ctx, book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
// 1) 首次刷新:抓到 2 章并落库。书架原先没有目录缓存,没有可比基准,算「已是最新」。
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 1 || res.Updated != 0 || res.Unchanged != 1 || res.Failed != 0 {
|
||||
t.Fatalf("首次刷新 = %+v,期望 total 1 / updated 0 / unchanged 1 / failed 0", res)
|
||||
}
|
||||
chapters, err := svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("目录缓存 = %d 章,期望 2", len(chapters))
|
||||
}
|
||||
|
||||
// 2) 站点多出一章:这才算「有新章节」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(strings.Replace(
|
||||
e2eTocHTML, "</ul>", `<li><a href="/book/1/c3.html">第三章 药老</a></li></ul>`, 1,
|
||||
)))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Updated != 1 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("出现新章节时 = %+v,期望 updated 1 / unchanged 0 / failed 0", res)
|
||||
}
|
||||
|
||||
// 3) 站点返回风控响应体(HTTP 200 但不是目录):规则解析成空列表且不报错,
|
||||
// 这种静默失败必须记失败,不能报成「已是最新」。
|
||||
tocHandler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"code":210,"message":"請到官網更新最新APP,請下載安裝正版之後等待1小時。"}`))
|
||||
}
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 || res.Updated != 0 {
|
||||
t.Fatalf("风控响应体 = %+v,期望 updated 0 / unchanged 0 / failed 1", res)
|
||||
}
|
||||
// 抓失败不能动已有缓存,否则阅读器会连本来能看的章节都读不到。
|
||||
chapters, err = svc.ListChapters(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(chapters) != 3 {
|
||||
t.Fatalf("抓失败后目录缓存 = %d 章,期望保持 3 章", len(chapters))
|
||||
}
|
||||
|
||||
// 4) 站点 404:抓取失败。
|
||||
tocHandler = func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) }
|
||||
res = refreshTocForTest(t, svc, ctx)
|
||||
if res.Failed != 1 || res.Unchanged != 0 {
|
||||
t.Fatalf("站点 404 = %+v,期望 unchanged 0 / failed 1", res)
|
||||
}
|
||||
}
|
||||
|
||||
// 本地书籍与没有书源信息的书籍不参与刷新,汇总里也不该出现。
|
||||
func TestRefreshBooksTocSkipsLocalAndSourcelessBooks(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
local := &model.ReaderBook{UserID: "u1", Name: "本地书", LocalPath: "/tmp/a.txt", BookURL: "x", Origin: "y"}
|
||||
sourceless := &model.ReaderBook{UserID: "u1", Name: "无书源", BookURL: "", Origin: ""}
|
||||
for _, b := range []*model.ReaderBook{local, sourceless} {
|
||||
if err := svc.repo.CreateBook(ctx, b); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
res := refreshTocForTest(t, svc, ctx)
|
||||
if res.Total != 0 || res.Updated != 0 || res.Unchanged != 0 || res.Failed != 0 {
|
||||
t.Fatalf("跳过本地/无源书籍时 = %+v,期望全 0", res)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package rule
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/PaesslerAG/jsonpath"
|
||||
@@ -332,13 +333,14 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
result = strings.Split(s, "\n")
|
||||
}
|
||||
if isUrl {
|
||||
// 这里必须接受任何切片形态:书源的下一页地址/章节地址规则常常由 JS 生成,
|
||||
// goja 导出的是 []any。早期只认 []string,JS 给的地址数组会被静默丢掉,
|
||||
// 表现为「nextTocUrl 明明有规则却永远不翻页」。
|
||||
var urlList []string
|
||||
if lst, ok := result.([]string); ok {
|
||||
for _, u := range lst {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
for _, u := range resultStrings(result) {
|
||||
abs := a.absolutize(u)
|
||||
if abs != "" && !containsStr(urlList, abs) {
|
||||
urlList = append(urlList, abs)
|
||||
}
|
||||
}
|
||||
return urlList, nil
|
||||
@@ -363,6 +365,32 @@ func (a *AnalyzeRule) getStringListRules(ruleList []*SourceRule, mContent any, i
|
||||
}
|
||||
}
|
||||
|
||||
// resultStrings 把任意分析结果归一成字符串切片([]string / []any / []*html.Node / 单值)。
|
||||
func resultStrings(result any) []string {
|
||||
switch t := result.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, anyToString(v))
|
||||
}
|
||||
return out
|
||||
case []*html.Node:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
out = append(out, outerHTML(v))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
return strings.Split(t, "\n")
|
||||
default:
|
||||
return []string{anyToString(result)}
|
||||
}
|
||||
}
|
||||
|
||||
// GetString 对应 getString(rule, mContent, isUrl)。
|
||||
func (a *AnalyzeRule) GetString(ruleStr string, mContent any, isUrl bool) (string, error) {
|
||||
if ruleStr == "" {
|
||||
@@ -423,10 +451,7 @@ func (a *AnalyzeRule) getStringRules(ruleList []*SourceRule, mContent any, isUrl
|
||||
if result == nil {
|
||||
result = ""
|
||||
}
|
||||
str := resultString(result)
|
||||
if strings.Contains(str, "&") {
|
||||
str = html.UnescapeString(str)
|
||||
}
|
||||
str := unescapeRuleEntities(resultString(result))
|
||||
if isUrl {
|
||||
if strings.TrimSpace(str) == "" {
|
||||
// 对应 legado:取值为空时回退 baseUrl,让相对地址还能解析。
|
||||
@@ -570,6 +595,49 @@ func applyReplaceRegex(result string, r ResolvedSourceRule) string {
|
||||
return regexReplaceAll(r.ReplaceRegex, result, r.Replacement)
|
||||
}
|
||||
|
||||
// entityRefRe 匹配一个 HTML 字符引用:命名实体(&、 )、十进制(')、
|
||||
// 十六进制(')。分号可选——旧式简写( 、&)在 HTML 文本里同样成立。
|
||||
var entityRefRe = regexp.MustCompile(`&(?:[a-zA-Z][a-zA-Z0-9]{1,31}|#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6});?`)
|
||||
|
||||
// unescapeRuleEntities 还原规则结果里的 HTML 实体(`&` → `&`、`'` → `'`)。
|
||||
//
|
||||
// 唯一的例外是「不带分号的旧式简写后面紧跟 = 或字母数字」这一种:HTML5 里这类写法
|
||||
// 属于历史匹配,而规则结果并不是纯 HTML 文本——正文、地址、查询串都在同一份字符串里。
|
||||
// 按浏览器语义无差别还原会把查询串吃掉:光遇聚合的段评地址
|
||||
// `…?item_id=X¶=1&source=QQ阅读` 会变成 `…?item_id=X¶=1&source=QQ阅读`,
|
||||
// 上游按缺参数处理返回空页面,段评面板就只剩一片空白(见 reader/comment.go)。
|
||||
func unescapeRuleEntities(s string) string {
|
||||
if !strings.Contains(s, "&") {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
last := 0
|
||||
for _, loc := range entityRefRe.FindAllStringIndex(s, -1) {
|
||||
match := s[loc[0]:loc[1]]
|
||||
if !strings.HasSuffix(match, ";") && loc[1] < len(s) && isEntityStopByte(s[loc[1]]) {
|
||||
continue
|
||||
}
|
||||
decoded := html.UnescapeString(match)
|
||||
if decoded == match {
|
||||
continue // 不是已知实体,原样保留
|
||||
}
|
||||
b.WriteString(s[last:loc[0]])
|
||||
b.WriteString(decoded)
|
||||
last = loc[1]
|
||||
}
|
||||
if last == 0 {
|
||||
return s
|
||||
}
|
||||
b.WriteString(s[last:])
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// isEntityStopByte 判断旧式简写实体后面这个字符是否让它失去实体语义
|
||||
// (HTML5 规定不带分号的引用后跟 = 或字母数字时按普通文本处理)。
|
||||
func isEntityStopByte(c byte) bool {
|
||||
return c == '=' || c >= '0' && c <= '9' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
}
|
||||
|
||||
// resultString 对应 Kotlin 的 result.toString()。
|
||||
func resultString(result any) string {
|
||||
switch t := result.(type) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 规则结果里的地址带查询串。html.UnescapeString 会按 HTML5 旧式简写把 ¶ 吃成
|
||||
// 「¶」,参数名变成 ¶ 之后上游按缺参数返回空页面——光遇聚合的段评面板一片空白
|
||||
// 就是这个原因(见 reader/comment.go、reader/browser_panel.go)。
|
||||
func TestUnescapeRuleEntitiesKeepsQueryString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "段评地址里的 ¶ 不能被当成实体",
|
||||
in: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
want: "https://v1.qingtian618.com/get_para_review?item_id=X¶=1&source=QQ%E9%98%85%E8%AF%BB",
|
||||
},
|
||||
{
|
||||
name: "¬ 开头的参数名同样不能被吃掉",
|
||||
in: "https://example.com/api?book=1¬ify=1",
|
||||
want: "https://example.com/api?book=1¬ify=1",
|
||||
},
|
||||
{
|
||||
name: "带分号的实体照旧还原",
|
||||
in: "url?a=1&b=2&c=3",
|
||||
want: "url?a=1&b=2&c=3",
|
||||
},
|
||||
{
|
||||
name: "数字实体照旧还原",
|
||||
in: "'引号' 'x' ©",
|
||||
want: "'引号' 'x' ©",
|
||||
},
|
||||
{
|
||||
name: "文字里的旧式简写(后面不是 = 或字母数字)照旧还原",
|
||||
in: "第一段 第二段© 2026×",
|
||||
want: "第一段\u00a0第二段© 2026×",
|
||||
},
|
||||
{
|
||||
name: "不是实体的 & 原样保留",
|
||||
in: "a & b &foo; c",
|
||||
want: "a & b &foo; c",
|
||||
},
|
||||
{
|
||||
name: "不含 & 的字符串直接放行",
|
||||
in: "魔法大陆。",
|
||||
want: "魔法大陆。",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := unescapeRuleEntities(c.in); got != c.want {
|
||||
t.Fatalf("unescapeRuleEntities(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:走 GetString 的规则结果同样不能把段评地址里的 ¶ 吃掉
|
||||
// (光遇聚合的正文规则是 <js>…</js>$.content 形态,最终取 $.content)。
|
||||
func TestGetStringCommentURLKeepsParaParam(t *testing.T) {
|
||||
const url = "https://v1.qingtian618.com/get_para_review?item_id=YmlkPTYxNDc4MiZjaWQ9MQ¶=11&source=QQ%E9%98%85%E8%AF%BB"
|
||||
payload := `{"content":"<p>正文<comment ident=\"` + url + `\" count=\"39\" /></p>"}`
|
||||
|
||||
a := NewAnalyzeRule()
|
||||
a.SetContent(payload, "https://v1.qingtian618.com")
|
||||
got, err := a.GetString("$.content", nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetString: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "¶=11") {
|
||||
t.Fatalf("段评地址里的 ¶=11 被改写了: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "\u00b6") {
|
||||
t.Fatalf("段评地址里出现了 ¶(HTML 旧式实体还原的产物): %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGetStringListURLFromJSArray URL 类规则(nextTocUrl / chapterUrl / coverUrl…)
|
||||
// 由 JS 生成时,goja 导出的是 []any。早期实现只认 []string,JS 给的地址数组会被
|
||||
// 静默丢掉,表现为「书源写了 nextTocUrl 却永远不翻页」。
|
||||
func TestGetStringListURLFromJSArray(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"total":399,"limit":100}`, "https://api.example.com/toc?limit=100&offset=0")
|
||||
ar.SetJSRunner(func(js string, result any) (any, error) {
|
||||
return NewJSRunner(JSConfig{BaseURL: "https://api.example.com/toc?limit=100&offset=0"}).Run(nil, js, result, "https://api.example.com/toc?limit=100&offset=0")
|
||||
})
|
||||
|
||||
// JS 返回数组(真实书源就是这种写法:一次给出全部后续页)
|
||||
rule := `<js>['https://api.example.com/toc?offset=100','https://api.example.com/toc?offset=200']</js>`
|
||||
urls, err := ar.GetStringList(rule, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 2 {
|
||||
t.Fatalf("拿到 %d 个地址,期望 2(JS 数组被丢掉了): %v", len(urls), urls)
|
||||
}
|
||||
if urls[0] != "https://api.example.com/toc?offset=100" || urls[1] != "https://api.example.com/toc?offset=200" {
|
||||
t.Fatalf("地址不对: %v", urls)
|
||||
}
|
||||
|
||||
// 相对地址要按当前页绝对化
|
||||
ruleRel := `<js>['/toc?offset=300']</js>`
|
||||
urls, err = ar.GetStringList(ruleRel, nil, true)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if len(urls) != 1 || !strings.HasPrefix(urls[0], "https://api.example.com/toc?offset=300") {
|
||||
t.Fatalf("相对地址没有绝对化: %v", urls)
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"hash"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 本文件对应 legado JsExtensions / JsEncodeUtils / RegexJsExtensions 中
|
||||
@@ -52,7 +55,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
req.Headers[k] = fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
body, _, _, err := r.cfg.Fetch(req)
|
||||
body, _, _, err := r.fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
if req.Unsupported != nil {
|
||||
bridgeErr(name, req.Unsupported)
|
||||
}
|
||||
respBody, finalURL, code, err := r.cfg.Fetch(req)
|
||||
respBody, finalURL, code, err := r.fetch(req)
|
||||
if err != nil {
|
||||
bridgeErr(name, err)
|
||||
}
|
||||
@@ -401,9 +404,127 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
set("getWebViewUA", func(call goja.FunctionCall) goja.Value {
|
||||
return vm.ToValue(defaultWebViewUA)
|
||||
})
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// 简繁转换(对应 legado ChineseUtils → quick-transfer)。这里按单字映射实现,
|
||||
// 见 zh_convert.go 的说明。
|
||||
set("t2s", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToSimplified(stringArg(call, 0))) })
|
||||
set("s2t", func(call goja.FunctionCall) goja.Value { return vm.ToValue(zhToTraditional(stringArg(call, 0))) })
|
||||
set("htmlFormat", func(call goja.FunctionCall) goja.Value { return vm.ToValue(stringArg(call, 0)) })
|
||||
// randomUUID:部分书源拿它生成设备标识/请求 ID(缺失会让整条规则抛异常)。
|
||||
set("randomUUID", func(call goja.FunctionCall) goja.Value { return vm.ToValue(uuid.NewString()) })
|
||||
// toNumChapter:章节标题里的数字规整成阿拉伯数字(见 zh_number.go)。
|
||||
set("toNumChapter", func(call goja.FunctionCall) goja.Value { return vm.ToValue(numChapter(stringArg(call, 0))) })
|
||||
// toURL(url[, baseUrl]):对应 legado JsURL(host/origin/pathname/searchParams)。
|
||||
set("toURL", func(call goja.FunctionCall) goja.Value {
|
||||
base := ""
|
||||
if len(call.Arguments) > 1 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
base = call.Arguments[1].String()
|
||||
}
|
||||
obj, err := newJSURLObject(vm, stringArg(call, 0), base)
|
||||
if err != nil {
|
||||
bridgeErr("toURL", err)
|
||||
}
|
||||
return obj
|
||||
})
|
||||
// logType:调试用,打印值的类型(对应 legado 的日志实现)。
|
||||
set("logType", func(call goja.FunctionCall) goja.Value {
|
||||
if r.cfg.Log != nil {
|
||||
if len(call.Arguments) == 0 || goja.IsNull(call.Arguments[0]) || goja.IsUndefined(call.Arguments[0]) {
|
||||
r.cfg.Log("null")
|
||||
} else {
|
||||
r.cfg.Log(fmt.Sprintf("%T", call.Arguments[0].Export()))
|
||||
}
|
||||
}
|
||||
return goja.Null()
|
||||
})
|
||||
|
||||
// ── 本地文件(对应 legado JsExtensions 的缓存/文件系列) ──
|
||||
//
|
||||
// 服务端把文件限制在自己的缓存目录(<cache>/reader/files)内,
|
||||
// 书源只能用相对路径(允许以 / 开头,downloadFile 的返回值即此形态)。
|
||||
set("downloadFile", func(call goja.FunctionCall) goja.Value {
|
||||
// 旧签名 downloadFile(contentHex, url):把 hex 字符串落成文件。
|
||||
if len(call.Arguments) >= 2 && !goja.IsUndefined(call.Arguments[1]) && !goja.IsNull(call.Arguments[1]) {
|
||||
url := stringArg(call, 1)
|
||||
raw, err := hex.DecodeString(strings.TrimSpace(stringArg(call, 0)))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
rel, err := writeCacheFile(r.cfg.CacheDir, cacheFileName(url), raw)
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
}
|
||||
rel, err := downloadToCache(r, stringArg(call, 0))
|
||||
if err != nil {
|
||||
bridgeErr("downloadFile", err)
|
||||
}
|
||||
return vm.ToValue(rel)
|
||||
})
|
||||
// cacheFile(url): 下载并缓存文本文件,返回文件**内容**(对应 legado 语义)。
|
||||
set("cacheFile", func(call goja.FunctionCall) goja.Value {
|
||||
url := stringArg(call, 0)
|
||||
if dir := readerFilesDir(r.cfg.CacheDir); dir != "" {
|
||||
if b, err := os.ReadFile(filepath.Join(dir, cacheFileName(url))); err == nil && len(b) > 0 {
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
}
|
||||
}
|
||||
rel, err := downloadToCache(r, url)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, rel)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
bridgeErr("cacheFile", err)
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("readFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return goja.Null()
|
||||
}
|
||||
return vm.ToValue(vm.NewArrayBuffer(b))
|
||||
})
|
||||
set("readTxtFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
if cs := stringArgOr(call, 1, ""); cs != "" {
|
||||
if s, derr := DecodeBytes(b, cs); derr == nil {
|
||||
return vm.ToValue(s)
|
||||
}
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("deleteFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
// getFile(path): 返回 File 对象(提供书源常用的 exists/length/name/delete/readText)。
|
||||
set("getFile", func(call goja.FunctionCall) goja.Value {
|
||||
full, err := resolveCacheFilePath(r.cfg.CacheDir, stringArg(call, 0))
|
||||
if err != nil {
|
||||
full = ""
|
||||
}
|
||||
return newFileObject(vm, full, stringArg(call, 0))
|
||||
})
|
||||
|
||||
// ── 书源会话状态(legado 中 `java` 与 `source` 是同一对象) ──
|
||||
if r.state != nil {
|
||||
@@ -496,22 +617,29 @@ func newJavaObject(vm *goja.Runtime, r *JSRunner, a *AnalyzeRule) *goja.Object {
|
||||
// open / searchBook —— legado 中仅做原生界面跳转(打开搜索页 / 登录页),
|
||||
// 服务端没有对应页面,谎报成功会让书源走错分支
|
||||
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持
|
||||
unsupported := func(name string) func(goja.FunctionCall) goja.Value {
|
||||
// 需要真正无头浏览器/本地文件系统的能力:明确抛出不支持,并把原因说清楚
|
||||
// (书源调试面板会把这句原样显示出来,用户能立刻判断该不该继续折腾这个源)。
|
||||
unsupported := func(name, reason string) func(goja.FunctionCall) goja.Value {
|
||||
return func(call goja.FunctionCall) goja.Value {
|
||||
panic(vm.ToValue("java." + name + " 需要浏览器或本地文件能力,服务端不支持"))
|
||||
panic(vm.ToValue(fmt.Sprintf("java.%s 服务端不支持:%s", name, reason)))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"webView", "webViewGetSource", "webViewGetOverrideUrl",
|
||||
"openVideoPlayer", "getVerificationCode",
|
||||
"importScript", "cacheFile", "downloadFile", "readFile", "readTxtFile", "deleteFile",
|
||||
} {
|
||||
set(name, unsupported(name, "需要无头浏览器(WebView)"))
|
||||
}
|
||||
for _, name := range []string{
|
||||
"unzipFile", "un7zFile", "unrarFile", "unArchiveFile", "getTxtInFolder",
|
||||
"getZipStringContent", "getZipByteArrayContent",
|
||||
"getRarStringContent", "get7zStringContent",
|
||||
} {
|
||||
set(name, unsupported(name))
|
||||
set(name, unsupported(name, "需要压缩包解压能力"))
|
||||
}
|
||||
// importScript 在 legado 里是「下载 JS 文件并 eval」,服务端可做但会引入
|
||||
// 任意脚本执行面,暂不支持,保持明确报错。
|
||||
set("importScript", unsupported("importScript", "服务端不支持动态加载外部脚本"))
|
||||
|
||||
return o
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// 本文件实现 legado JsExtensions 里的「本地文件」能力
|
||||
// (cacheFile / downloadFile / readFile / readTxtFile / deleteFile / getFile)。
|
||||
//
|
||||
// 与 legado 的语义保持一致:
|
||||
// - downloadFile(url) 下载到缓存目录,返回「相对缓存根目录」的路径;
|
||||
// - cacheFile(url) 下载(带缓存)后返回文件的**文本内容**(不是路径);
|
||||
// - readFile/readTxtFile/deleteFile/getFile 接受相对路径(允许以 / 开头),
|
||||
// 一律解析到缓存目录下。
|
||||
//
|
||||
// 服务端把缓存根目录固定为 <cache>/reader/files,并且只允许访问该目录内的文件,
|
||||
// 避免书源通过 `../../` 之类读到宿主上的任意文件。
|
||||
|
||||
// filesSubdir 书源文件缓存目录(相对 ReaderService 的缓存根目录)。
|
||||
const filesSubdir = "reader/files"
|
||||
|
||||
// readerFilesDir 返回书源文件缓存目录;未配置缓存目录时返回空串。
|
||||
func readerFilesDir(cacheDir string) string {
|
||||
if strings.TrimSpace(cacheDir) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(cacheDir, "reader", "files")
|
||||
}
|
||||
|
||||
// cacheFileName 计算某个 URL 在缓存目录里的文件名(对应 legado 的
|
||||
// `${md5Encode16(url)}.${type}`:md5 十六进制取中间 16 位,保留 URL 后缀)。
|
||||
func cacheFileName(rawURL string) string {
|
||||
name := md5Hex(rawURL, true)
|
||||
if ext := urlFileSuffix(rawURL); ext != "" {
|
||||
name += "." + ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// urlFileSuffix 取 URL 路径部分的后缀(不含 `.`),只接受字母数字(长度 1~8)。
|
||||
func urlFileSuffix(rawURL string) string {
|
||||
u := rawURL
|
||||
if i := strings.IndexAny(u, "?#"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if i := strings.LastIndex(u, "/"); i >= 0 {
|
||||
u = u[i+1:]
|
||||
}
|
||||
dot := strings.LastIndex(u, ".")
|
||||
if dot < 0 || dot == len(u)-1 {
|
||||
return ""
|
||||
}
|
||||
ext := u[dot+1:]
|
||||
if len(ext) > 8 {
|
||||
return ""
|
||||
}
|
||||
for i := 0; i < len(ext); i++ {
|
||||
c := ext[i]
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// resolveCacheFilePath 把书源给的相对路径解析成缓存目录下的绝对路径。
|
||||
// 允许以 `/` 开头(downloadFile 的返回值就是这种形态),但拒绝越出缓存目录。
|
||||
func resolveCacheFilePath(cacheDir, p string) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", fmt.Errorf("文件路径为空")
|
||||
}
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.Trim(p, `/\`)))
|
||||
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("非法文件路径: %s", p)
|
||||
}
|
||||
return filepath.Join(dir, rel), nil
|
||||
}
|
||||
|
||||
// writeCacheFile 把字节写入缓存目录,返回相对路径(以 / 开头,对应 legado)。
|
||||
func writeCacheFile(cacheDir, name string, data []byte) (string, error) {
|
||||
dir := readerFilesDir(cacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "/" + name, nil
|
||||
}
|
||||
|
||||
// downloadToCache 下载 URL 并落到缓存目录(已存在则直接复用),
|
||||
// 返回相对缓存根目录的路径。下载走书源会话,因此会带上书源请求头与 Cookie。
|
||||
func downloadToCache(r *JSRunner, rawURL string) (string, error) {
|
||||
dir := readerFilesDir(r.cfg.CacheDir)
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("未配置缓存目录")
|
||||
}
|
||||
name := cacheFileName(rawURL)
|
||||
full := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(full); err == nil && st.Size() > 0 {
|
||||
return "/" + name, nil
|
||||
}
|
||||
req, err := ParseAnalyzeUrlWithJS(rawURL, "", 0, r.cfg.BaseURL, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if req.Unsupported != nil {
|
||||
return "", req.Unsupported
|
||||
}
|
||||
// Raw:响应按原始字节返回,避免 charset 解码破坏二进制/非 UTF-8 文本。
|
||||
// 同时清掉 HexBody:URL 选项里的 `type` 在 legado 是文件后缀提示,
|
||||
// 这里不该把下载内容按 hex 字符串落盘。
|
||||
req.Raw = true
|
||||
req.HexBody = false
|
||||
body, _, code, err := r.fetch(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if code >= 400 {
|
||||
return "", fmt.Errorf("下载失败 HTTP %d: %s", code, rawURL)
|
||||
}
|
||||
return writeCacheFile(r.cfg.CacheDir, name, []byte(body))
|
||||
}
|
||||
|
||||
// decodeTextAuto 解码文本文件:UTF-8 BOM → 去 BOM;合法 UTF-8 → 原样;
|
||||
// 否则按 GBK 解(中文站点/书源最常见的另一种编码),失败再原样返回。
|
||||
func decodeTextAuto(b []byte) string {
|
||||
if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
return string(b[3:])
|
||||
}
|
||||
if utf8.Valid(b) {
|
||||
return string(b)
|
||||
}
|
||||
if s, err := DecodeBytes(b, "gbk"); err == nil && s != "" {
|
||||
return s
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// fileSuffixFromName 取路径的最后一段(getFile 的 name 语义)。
|
||||
func fileSuffixFromName(p string) string {
|
||||
return path.Base(strings.ReplaceAll(p, "\\", "/"))
|
||||
}
|
||||
|
||||
// newFileObject 构造 `java.getFile(path)` 返回的 File 等价物,
|
||||
// 提供书源常用的 exists / length / name / delete / readText。
|
||||
func newFileObject(vm *goja.Runtime, full, raw string) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
set := func(k string, v any) { _ = o.Set(k, v) }
|
||||
set("path", full)
|
||||
set("name", fileSuffixFromName(raw))
|
||||
set("exists", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
_, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil)
|
||||
})
|
||||
set("isDirectory", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
return vm.ToValue(err == nil && st.IsDir())
|
||||
})
|
||||
set("length", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
st, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return vm.ToValue(0)
|
||||
}
|
||||
return vm.ToValue(st.Size())
|
||||
})
|
||||
set("delete", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue(false)
|
||||
}
|
||||
return vm.ToValue(os.Remove(full) == nil)
|
||||
})
|
||||
set("readText", func(goja.FunctionCall) goja.Value {
|
||||
if full == "" {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
b, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return vm.ToValue("")
|
||||
}
|
||||
return vm.ToValue(decodeTextAuto(b))
|
||||
})
|
||||
set("toString", func(goja.FunctionCall) goja.Value { return vm.ToValue(full) })
|
||||
return o
|
||||
}
|
||||
@@ -98,6 +98,10 @@ func (r *JSRunner) openBrowser(req BrowserTask) error {
|
||||
return nil
|
||||
}
|
||||
if r.cfg.Browser != nil {
|
||||
// 宿主抓取待展示的页面同样是网络等待,不能吃掉 JS 执行预算
|
||||
// (与 awaitBrowser 同一套暂停机制,见 pauseTimeout 的说明)。
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.Browser.OpenBrowser(r.ctx(), req)
|
||||
}
|
||||
if r.state != nil {
|
||||
|
||||
@@ -11,5 +11,5 @@ var (
|
||||
// JS 引擎(goja + java.* 桥)在 P2 阶段接入,届时移除本错误路径。
|
||||
ErrJsUnsupported = errors.New("书源使用了 JS 规则,当前阶段暂不支持")
|
||||
// ErrWebJSUnsupported 书源依赖 webView/webJs 抓取,服务端无头浏览器不在支持范围。
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView 抓取,暂不支持")
|
||||
ErrWebJSUnsupported = errors.New("书源依赖 webView/webJs(需要无头浏览器),服务端不支持;该源请在阅读 App 内使用")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
)
|
||||
|
||||
// 本文件:java 文件接口(cacheFile / downloadFile / readTxtFile / deleteFile)
|
||||
// 的语义回归。对应 legado JsExtensions:
|
||||
// - cacheFile(url) → 返回文件**文本内容**(不是路径);
|
||||
// - downloadFile(url) → 返回相对缓存根目录的路径;
|
||||
// - readTxtFile(path[, charset]) → 返回文本;
|
||||
// - 只允许访问缓存目录内的文件。
|
||||
|
||||
func newFileTestRunner(cacheDir, baseURL string, hits *int32) *JSRunner {
|
||||
return NewJSRunner(JSConfig{
|
||||
CacheDir: cacheDir,
|
||||
BaseURL: baseURL,
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
if hits != nil {
|
||||
atomic.AddInt32(hits, 1)
|
||||
}
|
||||
resp, err := http.Get(req.URL) //nolint:gosec // 测试内固定 httptest 地址
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return string(b), req.URL, resp.StatusCode, nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestCacheFileReturnsTextContent(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("第一章 开始\n第二章 继续"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, &hits)
|
||||
ar := NewAnalyzeRule()
|
||||
v, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cacheFile 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第一章 开始\n第二章 继续" {
|
||||
t.Fatalf("cacheFile 应返回文本内容,实际 %q", got)
|
||||
}
|
||||
// 第二次应命中缓存,不再发起请求。
|
||||
if _, err := r.Run(ar, "java.cacheFile('"+srv.URL+"/vol.txt')", nil, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := atomic.LoadInt32(&hits); n != 1 {
|
||||
t.Fatalf("重复调用应命中缓存(请求次数=%d)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheFileDecodesGBK(t *testing.T) {
|
||||
gbk, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte("中文内容测试"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(gbk)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := newFileTestRunner(t.TempDir(), srv.URL, nil)
|
||||
v, err := r.Run(NewAnalyzeRule(), "java.cacheFile('"+srv.URL+"/gbk.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "中文内容测试" {
|
||||
t.Fatalf("GBK 文本未正确解码: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadFileThenReadTxtFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("正文内容"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cacheDir := t.TempDir()
|
||||
r := newFileTestRunner(cacheDir, srv.URL, nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
rawPath, err := r.Run(ar, "java.downloadFile('"+srv.URL+"/a.txt')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := anyToString(rawPath)
|
||||
if !strings.HasPrefix(rel, "/") || !strings.HasSuffix(rel, ".txt") {
|
||||
t.Fatalf("downloadFile 应返回相对缓存路径,实际 %q", rel)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cacheDir, "reader", "files", strings.TrimPrefix(rel, "/"))); err != nil {
|
||||
t.Fatalf("文件未落到缓存目录: %v", err)
|
||||
}
|
||||
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+rel+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v); got != "正文内容" {
|
||||
t.Fatalf("readTxtFile = %q", got)
|
||||
}
|
||||
|
||||
del, err := r.Run(ar, "String(java.deleteFile('"+rel+"'))", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anyToString(del) != "true" {
|
||||
t.Fatalf("deleteFile 应返回 true,实际 %q", anyToString(del))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsRejectPathTraversal(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
secret := filepath.Join(cacheDir, "secret.txt")
|
||||
if err := os.WriteFile(secret, []byte("不该被读到"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := newFileTestRunner(cacheDir, "", nil)
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
for _, p := range []string{"/../../secret.txt", "../secret.txt", "/../reader/files/../../secret.txt"} {
|
||||
v, err := r.Run(ar, "java.readTxtFile('"+p+"')", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 不应报错: %v", p, err)
|
||||
}
|
||||
if got := anyToString(v); strings.Contains(got, "不该被读到") {
|
||||
t.Fatalf("越权读到了缓存目录外的文件(%s)", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileOpsWithoutCacheDirFail(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
_, err := r.Run(NewAnalyzeRule(), "java.cacheFile('https://example.com/a.txt')", nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "缓存目录") {
|
||||
t.Fatalf("未配置缓存目录时应明确报错,实际 %v", err)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ type JSConfig struct {
|
||||
// Browser 宿主浏览器实现(java.startBrowser / startBrowserAwait)。
|
||||
// nil 时这两个函数抛出不支持错误。
|
||||
Browser BrowserHost
|
||||
// CacheDir 书源文件缓存根目录(java.downloadFile / cacheFile 落盘用)。
|
||||
// 为空时这些函数抛出明确错误。
|
||||
CacheDir string
|
||||
// Ctx 本次执行的可取消上下文,透传给 BrowserHost 的等待。
|
||||
Ctx context.Context
|
||||
}
|
||||
@@ -177,6 +180,22 @@ func (r *JSRunner) pauseTimeout() func() {
|
||||
return g.Pause()
|
||||
}
|
||||
|
||||
// fetch 执行一次桥接网络请求,等待期间暂停 JS 超时看门狗。
|
||||
//
|
||||
// 书源会把「线路重试」写进规则 JS:光遇聚合的 request() 会串行试 7 条线路,单条
|
||||
// 线路最长可能等到客户端的 30s 超时。不暂停的话整条规则会被 10s 的 JS 超时打断,
|
||||
// 而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是「线路
|
||||
// 还在重试,接口已经 400」。java.startBrowserAwait 等待人工操作时用的是同一套暂停
|
||||
// 机制。暂停只覆盖网络等待,纯 CPU 死循环仍然受 Timeout 约束。
|
||||
func (r *JSRunner) fetch(req *Request) (string, string, int, error) {
|
||||
if r.cfg.Fetch == nil {
|
||||
return "", "", 0, ErrJsUnsupported
|
||||
}
|
||||
resume := r.pauseTimeout()
|
||||
defer resume()
|
||||
return r.cfg.Fetch(req)
|
||||
}
|
||||
|
||||
// NewJSRunner 创建运行时:注入全局对象 cookie / cache / source,并执行 jsLib。
|
||||
func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm := goja.New()
|
||||
@@ -195,11 +214,100 @@ func NewJSRunner(cfg JSConfig) *JSRunner {
|
||||
vm.Set("source", srcObj)
|
||||
// java 也要在 jsLib 之前就位(jsLib 顶层可能引用 java.*)。
|
||||
r.installJava(nil)
|
||||
// Packages(Java 类命名空间)同样要在 jsLib 之前注入:不少书源在 jsLib 或
|
||||
// 目录/正文规则里用 `new Packages.java.util.LinkedHashMap()` 这类写法。
|
||||
r.installPackages()
|
||||
// 部分源把 source 的方法也当 java 成员用(同一 Kotlin 对象暴露两份)。
|
||||
r.loadJSLib()
|
||||
return r
|
||||
}
|
||||
|
||||
// installPackages 注入 Java 风格类命名空间(Packages.java.util.*)。
|
||||
//
|
||||
// goja 是纯 JS 运行时,没有 Java。书源(尤其照搬 Java 教程写的)常用
|
||||
// `new Packages.java.util.ArrayList()` / `LinkedHashMap` / `HashMap` 来拼
|
||||
// 目录列表和请求头,缺了它整条规则直接 ReferenceError(拷贝漫画轻小说源的
|
||||
// 目录规则就是这么写的)。
|
||||
//
|
||||
// 实现要点:容器用「真正的 JS 数组 / 对象」,方法用 Object.defineProperty 定义为
|
||||
// 不可枚举属性。这样容器交给引擎侧(GetElements/GetString)时导出的是干净的
|
||||
// 数组([]any)或映射(map[string]any),而不会把 add/put 这些方法也当成元素或
|
||||
// 请求头带出去。
|
||||
func (r *JSRunner) installPackages() {
|
||||
if _, err := r.vm.RunString(packagesPreamble); err != nil {
|
||||
// 注入失败不影响其它规则,只是 Packages.* 不可用。
|
||||
r.jsLibErr = fmt.Errorf("Packages 环境初始化失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// packagesPreamble 定义全局 Packages(以及与之等价的 java.util / java.lang 常用类)。
|
||||
const packagesPreamble = `
|
||||
var Packages = (function () {
|
||||
function def(o, k, v) {
|
||||
Object.defineProperty(o, k, { value: v, enumerable: false, writable: true, configurable: true });
|
||||
}
|
||||
function makeMap() {
|
||||
var m = {};
|
||||
def(m, 'put', function (k, v) { m[k] = v; return v; });
|
||||
def(m, 'putAll', function (o) { if (o) { for (var k in o) { m[k] = o[k]; } } });
|
||||
def(m, 'get', function (k) { return m[k] === undefined ? null : m[k]; });
|
||||
def(m, 'getOrDefault', function (k, d) { return m[k] === undefined ? d : m[k]; });
|
||||
def(m, 'containsKey', function (k) { return Object.prototype.hasOwnProperty.call(m, k); });
|
||||
def(m, 'containsValue', function (v) { for (var k in m) { if (m[k] === v) { return true; } } return false; });
|
||||
def(m, 'remove', function (k) { var v = m[k]; delete m[k]; return v; });
|
||||
def(m, 'size', function () { return Object.keys(m).length; });
|
||||
def(m, 'isEmpty', function () { return Object.keys(m).length === 0; });
|
||||
def(m, 'clear', function () { for (var k in m) { delete m[k]; } });
|
||||
def(m, 'keySet', function () { return Object.keys(m); });
|
||||
def(m, 'values', function () { var a = []; for (var k in m) { a.push(m[k]); } return a; });
|
||||
def(m, 'entrySet', function () { var a = []; for (var k in m) { a.push({ key: k, value: m[k] }); } return a; });
|
||||
def(m, 'toString', function () { return JSON.stringify(m); });
|
||||
return m;
|
||||
}
|
||||
function makeList() {
|
||||
var a = [];
|
||||
def(a, 'add', function (x) { a.push(x); return true; });
|
||||
def(a, 'addAll', function (xs) { if (xs) { for (var i = 0; i < xs.length; i++) { a.push(xs[i]); } } return true; });
|
||||
def(a, 'get', function (i) { return a[i]; });
|
||||
def(a, 'size', function () { return a.length; });
|
||||
def(a, 'isEmpty', function () { return a.length === 0; });
|
||||
def(a, 'contains', function (x) { return a.indexOf(x) >= 0; });
|
||||
def(a, 'remove', function (i) { return a.splice(i, 1)[0]; });
|
||||
def(a, 'clear', function () { a.length = 0; });
|
||||
def(a, 'toArray', function () { return a.slice(); });
|
||||
def(a, 'toString', function () { return a.join(','); });
|
||||
return a;
|
||||
}
|
||||
var util = {};
|
||||
var mapNames = ['LinkedHashMap', 'HashMap', 'TreeMap', 'Hashtable', 'ConcurrentHashMap', 'LinkedTreeMap'];
|
||||
for (var i = 0; i < mapNames.length; i++) { util[mapNames[i]] = makeMap; }
|
||||
var listNames = ['ArrayList', 'LinkedList', 'Vector'];
|
||||
for (var j = 0; j < listNames.length; j++) { util[listNames[j]] = makeList; }
|
||||
util.HashSet = makeList;
|
||||
util.Arrays = { asList: function () { return Array.prototype.slice.call(arguments); } };
|
||||
util.Collections = {
|
||||
emptyList: function () { return []; },
|
||||
emptyMap: function () { return makeMap(); },
|
||||
singletonList: function (x) { return [x]; }
|
||||
};
|
||||
var lang = {
|
||||
String: function (v) { return v == null ? '' : String(v); },
|
||||
Integer: function (v) { return parseInt(v, 10) || 0; },
|
||||
Long: function (v) { return parseInt(v, 10) || 0; },
|
||||
Double: function (v) { return parseFloat(v) || 0; },
|
||||
Boolean: function (v) { return !!v; },
|
||||
StringBuilder: function () {
|
||||
var s = '';
|
||||
var o = {};
|
||||
def(o, 'append', function (x) { s += (x == null ? '' : x); return o; });
|
||||
def(o, 'toString', function () { return s; });
|
||||
return o;
|
||||
}
|
||||
};
|
||||
return { java: { util: util, lang: lang } };
|
||||
})();
|
||||
`
|
||||
|
||||
// installJava 安装/刷新本次执行可见的 java 对象(桥回指定解析器)。
|
||||
func (r *JSRunner) installJava(a *AnalyzeRule) {
|
||||
r.vm.Set("java", newJavaObject(r.vm, r, a))
|
||||
@@ -383,7 +491,7 @@ func (r *JSRunner) Run(a *AnalyzeRule, js string, result any, baseURL string) (a
|
||||
}
|
||||
vm.Set("nextChapterUrl", nil)
|
||||
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
prog, err := compileRuleJS(js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("JS 编译失败: %w", err)
|
||||
}
|
||||
@@ -425,6 +533,31 @@ func scopedRuleJS(js string) string {
|
||||
return "{\n" + js + "\n}"
|
||||
}
|
||||
|
||||
// functionRuleJS 把规则 JS 包成函数体后求值。
|
||||
//
|
||||
// legado 允许书源规则用顶层 `return` 提前返回(拷贝漫画轻小说源的正文规则
|
||||
// 就在 if 分支里 `return '<img ...>'`),而 JS 脚本语法不允许顶层 return,
|
||||
// 只有函数体允许。这里作为块形式的兜底。
|
||||
func functionRuleJS(js string) string {
|
||||
return "(function(){\n" + js + "\n})()"
|
||||
}
|
||||
|
||||
// compileRuleJS 编译一段书源规则 JS。
|
||||
//
|
||||
// 优先用「块」形式:块的完成值就是最后一条语句的值,大量书源依赖它
|
||||
// (如 `@js:1+2` 直接产出 3),且块作用域能隔离顶层 let/const(见 scopedRuleJS)。
|
||||
// 块形式编译失败时退回「函数体」形式,兼容 legado 允许的顶层 return。
|
||||
func compileRuleJS(js string) (*goja.Program, error) {
|
||||
prog, err := compileCached(scopedRuleJS(js))
|
||||
if err == nil {
|
||||
return prog, nil
|
||||
}
|
||||
if prog2, err2 := compileCached(functionRuleJS(js)); err2 == nil {
|
||||
return prog2, nil
|
||||
}
|
||||
return nil, err // 两种形式都编译不过,返回块形式的错误(更贴近书源原文)
|
||||
}
|
||||
|
||||
// exportValue 把 JS 返回值转为 Go 值(字符串/数值/映射/切片)。
|
||||
func exportValue(v goja.Value) any {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
@@ -479,6 +612,9 @@ func newResponseObject(vm *goja.Runtime, body string, code int, finalURL string,
|
||||
}
|
||||
mustSet("body", func(call goja.FunctionCall) goja.Value { return vm.ToValue(body) })
|
||||
mustSet("bodyStr", body)
|
||||
// bodyAsBytes:部分书源用 `java.bytesToStr(resp.bodyAsBytes(), enc)` 处理
|
||||
// 非 UTF-8(GBK)正文(拷贝漫画轻小说源即如此)。
|
||||
mustSet("bodyAsBytes", func(call goja.FunctionCall) goja.Value { return vm.ToValue(vm.NewArrayBuffer([]byte(body))) })
|
||||
mustSet("code", func(call goja.FunctionCall) goja.Value { return vm.ToValue(code) })
|
||||
mustSet("url", func(call goja.FunctionCall) goja.Value { return vm.ToValue(finalURL) })
|
||||
mustSet("header", func(call goja.FunctionCall) goja.Value {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ─── 基础执行与绑定 ─────────────────────────────────────────────────────────
|
||||
@@ -46,6 +47,39 @@ func TestJSTimeoutInterrupt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestJSTimeoutPausedDuringFetch 网络等待期间不能被 JS 超时打断。
|
||||
//
|
||||
// 回归:书源把「线路重试」写在规则 JS 里(光遇聚合的 request() 串行试 7 条线路,
|
||||
// 单条最长等到 HTTP 客户端超时)。看门狗不暂停的话,整条规则会被 10s 的 JS 超时
|
||||
// 中断,而这个中断是 goja 的 Go panic,书源自己写的 try/catch 接不住——表现就是
|
||||
// 「线路还在重试,接口已经 400」。
|
||||
func TestJSTimeoutPausedDuringFetch(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{
|
||||
Timeout: 100 * time.Millisecond,
|
||||
Fetch: func(req *Request) (string, string, int, error) {
|
||||
time.Sleep(400 * time.Millisecond) // 远超过 JS 超时:模拟慢线路
|
||||
return `{"content":"正文"}`, req.URL, 200, nil
|
||||
},
|
||||
})
|
||||
ar := NewAnalyzeRule()
|
||||
v, err := r.Run(ar, `(function(){
|
||||
try { return java.ajax('https://slow.example.com/content'); }
|
||||
catch (e) { return 'caught:' + e; }
|
||||
})()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("网络等待期间不应触发 JS 超时: %v", err)
|
||||
}
|
||||
if got := anyToString(v); !strings.Contains(got, "正文") {
|
||||
t.Fatalf("ajax 返回值 = %q,期望上游正文", got)
|
||||
}
|
||||
|
||||
// 暂停只覆盖网络等待:回到 JS 里的纯 CPU 死循环仍然要被超时打断。
|
||||
_, err = r.Run(ar, `java.ajax('https://slow.example.com/content'); while(true){}`, nil, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("网络等待之后的 CPU 死循环应当仍然超时,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 规则引擎中的 JS({{}} / @js: / <js>) ──────────────────────────────────
|
||||
|
||||
func TestAnalyzeRuleJSEval(t *testing.T) {
|
||||
@@ -298,14 +332,51 @@ func TestJSUnsupportedStillWorks(t *testing.T) {
|
||||
|
||||
// ─── 沙箱边界 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestJSSandboxUnsupported 服务端不接受书源读取宿主任意文件。
|
||||
//
|
||||
// 文件类接口(cacheFile/downloadFile/readTxtFile/...)已实现,但路径被限制在
|
||||
// 书源自己的缓存目录内:越权路径安全地返回空,而不是把 /etc/passwd 交出去,
|
||||
// 也不应因为越权路径抛异常(否则书源会误判为「线路故障」反复重试)。
|
||||
// 真正无实现的能力(解压包、无头浏览器等)继续明确抛「不支持」。
|
||||
func TestJSSandboxUnsupported(t *testing.T) {
|
||||
r := newTestRunner()
|
||||
a := NewAnalyzeRule()
|
||||
a.SetJSRunner(r.ForAnalyzer(a))
|
||||
a.SetContent("", "")
|
||||
_, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("expected sandbox error, got %v", err)
|
||||
got, err := a.GetString(`@js:java.readTxtFile('/etc/passwd')`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("越权路径应安全返回空而不是抛异常: %v", err)
|
||||
}
|
||||
if strings.Contains(got, "root:") || strings.Contains(got, "/bin/") {
|
||||
t.Fatalf("沙箱被突破,读到了宿主文件: %q", got)
|
||||
}
|
||||
for _, call := range []string{`java.unzipFile('/tmp/a.zip')`, `java.webView('x')`} {
|
||||
if _, err := a.GetString(`@js:`+call, nil, false); err == nil || !strings.Contains(err.Error(), "不支持") {
|
||||
t.Fatalf("%s 应继续抛「不支持」,实际 %v", call, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuleJSTopLevelReturn legado 允许规则 JS 顶层 return;无 return 时仍取最后
|
||||
// 一条语句的值(`@js:1+2` → 3)。
|
||||
func TestRuleJSTopLevelReturn(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
v, err := r.Run(ar, "if (1) { return '早退'; }\nreturn '兜底';", nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("顶层 return 应可用: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "早退" {
|
||||
t.Fatalf("top-level return = %q, want 早退", got)
|
||||
}
|
||||
|
||||
v2, err := r.Run(ar, "1 + 2", nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := anyToString(v2); got != "3" {
|
||||
t.Fatalf("最后一条语句的值 = %q, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -375,11 +375,20 @@ func initLoginInfoFromUI(props map[string]any) map[string]string {
|
||||
}
|
||||
|
||||
// ─── cache 对象(对应 legado CacheManager 注入的 `cache`) ──────────────────
|
||||
|
||||
//
|
||||
// legado 的 cache 有两套存储:put/get/delete 落持久缓存(ACache),
|
||||
// putMemory/getFromMemory 落进程内内存缓存。书源靠后者记录「这条段评点过几次」
|
||||
// 这类临时状态——光遇聚合的 paraForAndroid 每一段带段评的文字都会调
|
||||
// cache.putMemory(url, 0),缺了它整条正文规则会抛 TypeError 直接失败。
|
||||
// 两套存储分开,否则 getFromMemory 会读到 put 写进去的持久值。
|
||||
var jsCache = struct {
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
}{m: map[string]string{}}
|
||||
mu sync.Mutex
|
||||
m map[string]string
|
||||
mem map[string]string
|
||||
}{m: map[string]string{}, mem: map[string]string{}}
|
||||
|
||||
// jsCacheMaxEntries 单套存储的条目上限:超了整体清空,避免书源把内存吃满。
|
||||
const jsCacheMaxEntries = 4096
|
||||
|
||||
func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
o := vm.NewObject()
|
||||
@@ -388,37 +397,52 @@ func newCacheObject(vm *goja.Runtime) *goja.Object {
|
||||
panic(vm.ToValue(err.Error()))
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
// 两套存储共用同一份读写实现,只有落点不同。
|
||||
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)
|
||||
}
|
||||
jsCache.mu.Lock()
|
||||
if len(jsCache.m) >= 4096 {
|
||||
jsCache.m = map[string]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(v)
|
||||
}
|
||||
jsCache.m[key] = val
|
||||
jsCache.mu.Unlock()
|
||||
return vm.ToValue(val)
|
||||
})
|
||||
set("get", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
v, ok := jsCache.m[key]
|
||||
jsCache.mu.Unlock()
|
||||
if !ok {
|
||||
}
|
||||
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()
|
||||
}
|
||||
return vm.ToValue(v)
|
||||
})
|
||||
set("delete", func(call goja.FunctionCall) goja.Value {
|
||||
key := stringArg(call, 0)
|
||||
jsCache.mu.Lock()
|
||||
delete(jsCache.m, key)
|
||||
jsCache.mu.Unlock()
|
||||
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 o
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestCacheMemoryRoundTrip 内存缓存(cache.putMemory / cache.getFromMemory)要能读写。
|
||||
//
|
||||
// 回归:「cache 对象只有 put/get/delete」曾让光遇聚合的正文规则整条失败——
|
||||
// paraForAndroid 每一段带段评的文字都会调 cache.putMemory(url, 0),缺了它就抛
|
||||
// TypeError: Object has no member 'putMemory',正文接口直接 400。
|
||||
func TestCacheMemoryRoundTrip(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
if _, err := r.Run(ar, `cache.putMemory('__test_mem', 3)`, nil, ""); err != nil {
|
||||
t.Fatalf("cache.putMemory 失败: %v", err)
|
||||
}
|
||||
v, err := r.Run(ar, `String(cache.getFromMemory('__test_mem'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "3" {
|
||||
t.Fatalf("getFromMemory = %q,期望 3", got)
|
||||
}
|
||||
|
||||
// 缺省返回 null(书源会写 `cache.getFromMemory(k) || ''` 这类兜底)。
|
||||
v, err = r.Run(ar, `String(cache.getFromMemory('__test_mem_missing'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("缺失键 = %q,期望 null", got)
|
||||
}
|
||||
|
||||
// 持久缓存与内存缓存是两套存储(legado 的 put/get 走 ACache,putMemory 走内存),
|
||||
// 互不串门,否则书源会读到本该过期的值。
|
||||
if _, err := r.Run(ar, `cache.put('__test_persist', 'p')`, nil, ""); err != nil {
|
||||
t.Fatalf("cache.put 失败: %v", err)
|
||||
}
|
||||
v, err = r.Run(ar, `String(cache.getFromMemory('__test_persist'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.getFromMemory 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("getFromMemory 读到了持久缓存的值: %q", got)
|
||||
}
|
||||
v, err = r.Run(ar, `String(cache.get('__test_mem'))`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("cache.get 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "null" {
|
||||
t.Fatalf("cache.get 读到了内存缓存的值: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package rule
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -30,14 +32,56 @@ func jsonRead(root any, path string) any {
|
||||
if root == nil || path == "" {
|
||||
return nil
|
||||
}
|
||||
v, err := jsonpathGet(path, root)
|
||||
v, err := jsonpathGet(normalizeJSONPath(path), root)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// normalizeJSONPath 把 legado(Jayway)允许、而 PaesslerAG/jsonpath 拒绝的写法
|
||||
// 归一化成合法路径,共两处差异:
|
||||
//
|
||||
// 1. 缺根 `$`:Jayway 要求路径以 `$`/`@` 开头,legado 书源却常直接写
|
||||
// `results.list`、`results.list.[*]`。PaesslerAG 对「不含 `[]`/`*` 的裸路径」
|
||||
// 恰好能容忍(内部按 `$.` 解析),但一旦出现 `[*]` 就报
|
||||
// `unexpected "*" while scanning extensions`。这里统一补根。
|
||||
// 2. 点后紧跟方括号:`results.list.[*]`、`$.a.[0]`、`$.a.['k']`。Jayway 里
|
||||
// `.[` 等价于 `[`,PaesslerAG 会报 `unexpected "[" while scanning field`。
|
||||
//
|
||||
// 两处都会让整条规则静默返回空——表现为「书源明明有效却搜不出任何结果」
|
||||
// (拷贝轻小说源的搜索列表规则就是 `results.list.[*]`)。
|
||||
func normalizeJSONPath(path string) string {
|
||||
p := strings.TrimSpace(path)
|
||||
if p == "" {
|
||||
return path
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(p, "$"), strings.HasPrefix(p, "@"):
|
||||
// 已有根,保持
|
||||
case strings.HasPrefix(p, "."):
|
||||
p = "$" + p // `..a` 这类递归写法,补 `$` 而不是 `$.`
|
||||
default:
|
||||
p = "$." + p
|
||||
}
|
||||
if !strings.Contains(p, ".[") {
|
||||
return p
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(p))
|
||||
for i := 0; i < len(p); i++ {
|
||||
// 丢掉 `[` 前多余的点;递归下降 `..[` 保持原样。
|
||||
if p[i] == '.' && i+1 < len(p) && p[i+1] == '[' && i > 0 && p[i-1] != '.' {
|
||||
continue
|
||||
}
|
||||
b.WriteByte(p[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsonValueToString 对应 Kotlin 的 ob.toString() / joinToString("\n")。
|
||||
// 列表按 "\n" 拼接(legado 的 getString 就是这么做的);对象走 Java 的 Map.toString
|
||||
// 形态(见 javaValueString),书源的正则大量依赖这个形态。
|
||||
func jsonValueToString(ob any) string {
|
||||
switch t := ob.(type) {
|
||||
case nil:
|
||||
@@ -51,7 +95,63 @@ func jsonValueToString(ob any) string {
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
default:
|
||||
return anyToString(t)
|
||||
return javaValueString(t)
|
||||
}
|
||||
}
|
||||
|
||||
// javaValueString 按 Java 的 Map/List toString 形态输出 JSON 值。
|
||||
//
|
||||
// 书源规则里的正则就是照着这个形态写的,最典型的是拷贝漫画的作者规则
|
||||
// `$.author##.*name=(.*?)\,.*##$1`:它期望拿到 `{name=岸本斉史, alias=…}`。
|
||||
// 之前这里用 Go 的 fmt 输出 `map[alias:… name:…]`,`name=` 根本匹配不到,
|
||||
// 作者字段就退化成一整串 map 文本。
|
||||
//
|
||||
// 已知差异:Java 的 LinkedTreeMap 保留 JSON 里的键顺序,而 Go 的
|
||||
// map[string]interface{} 不保序(JSONPath 库也只认这个类型),这里按 **键排序**
|
||||
// 输出以保证确定性。绝大多数「取某个键」的正则不受影响,但依赖原始键序的正则
|
||||
// (如 `.*name=(.*?)\,` 且 name 是最后一个键)仍可能与阅读 App 的结果不同。
|
||||
func javaValueString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, e := range t {
|
||||
parts[i] = javaValueString(e)
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(t))
|
||||
for k := range t {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var sb strings.Builder
|
||||
sb.WriteByte('{')
|
||||
for i, k := range keys {
|
||||
if i > 0 {
|
||||
sb.WriteString(", ")
|
||||
}
|
||||
sb.WriteString(k)
|
||||
sb.WriteByte('=')
|
||||
sb.WriteString(javaValueString(t[k]))
|
||||
}
|
||||
sb.WriteByte('}')
|
||||
return sb.String()
|
||||
default:
|
||||
return anyToString(v)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// newJSURLObject 构造 legado JsURL 的等效对象(java.toURL)。
|
||||
//
|
||||
// 对应 io.legado.app.utils.JsURL:它只暴露四个属性 ——
|
||||
//
|
||||
// host 主机名
|
||||
// origin "scheme://host[:port]"(默认端口不写)
|
||||
// pathname 路径(原样,未解码)
|
||||
// searchParams 查询参数(值已 URL 解码;同名只留最后一个,与 legado 一致)
|
||||
//
|
||||
// legado 返回的是 Java Map,Rhino 下书源会写 searchParams.get("x"),
|
||||
// 而 goja 里更自然的是 searchParams.x,所以两者都支持。
|
||||
func newJSURLObject(vm *goja.Runtime, raw, base string) (*goja.Object, error) {
|
||||
u, err := parseJSURL(raw, base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
obj := vm.NewObject()
|
||||
origin := u.Scheme + "://" + u.Hostname()
|
||||
if port := u.Port(); port != "" {
|
||||
origin += ":" + port
|
||||
}
|
||||
_ = obj.Set("host", u.Hostname())
|
||||
_ = obj.Set("origin", origin)
|
||||
_ = obj.Set("pathname", u.EscapedPath())
|
||||
|
||||
params := vm.NewObject()
|
||||
for key, values := range u.Query() {
|
||||
if len(values) == 0 {
|
||||
continue
|
||||
}
|
||||
_ = params.Set(key, values[len(values)-1])
|
||||
}
|
||||
// searchParams.get(name):兼容 Java Map 的取法;不存在时给 null(对应 Java 的 null)。
|
||||
get := func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return goja.Null()
|
||||
}
|
||||
v := params.Get(call.Arguments[0].String())
|
||||
if v == nil || goja.IsUndefined(v) {
|
||||
return goja.Null()
|
||||
}
|
||||
return v
|
||||
}
|
||||
_ = params.Set("get", get)
|
||||
_ = obj.Set("searchParams", params)
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// parseJSURL 解析 java.toURL 的入参。base 非空时按基础地址解析相对路径
|
||||
// (对应 Java 的 URL(base, url));解析不出绝对地址时报错(Java 会抛 MalformedURLException)。
|
||||
func parseJSURL(raw, base string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("toURL: 地址为空")
|
||||
}
|
||||
if base != "" {
|
||||
b, err := url.Parse(strings.TrimSpace(base))
|
||||
if err != nil || !b.IsAbs() {
|
||||
return nil, fmt.Errorf("toURL: baseUrl 不是绝对地址: %s", base)
|
||||
}
|
||||
rel, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
u := b.ResolveReference(rel)
|
||||
if !u.IsAbs() || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 解析结果不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("toURL: %v", err)
|
||||
}
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
return nil, fmt.Errorf("toURL: 不是绝对地址: %s", raw)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestJavaValueStringMatchesJavaMapToString 书源正则依赖 Java 的 Map.toString 形态。
|
||||
// 真实案例:拷贝漫画的 author 规则 `$.author##.*name=(.*?)\,.*##$1`,
|
||||
// 期望拿到 `{name=岸本斉史, alias=…}`;Go 的 `map[...]` 形态匹配不到,作者会退化成一串 map 文本。
|
||||
func TestJavaValueStringMatchesJavaMapToString(t *testing.T) {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"author":[{"name":"岸本斉史","alias":"岸本齐史,キシモトマサ","path_word":"anbenqishi"}]}`, "https://api.example.com")
|
||||
|
||||
got, err := ar.GetString(`$.author##.*name=(.*?)\,.*##$1`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got != "岸本斉史" {
|
||||
t.Fatalf("author = %q,期望 岸本斉史(Java Map 形态没对上)", got)
|
||||
}
|
||||
|
||||
// 对象/数组/标量的字符串形态
|
||||
str, err := ar.GetString(`$.author[0]`, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
for _, want := range []string{"{", "name=岸本斉史", "alias=岸本齐史,キシモトマサ", "path_word=anbenqishi"} {
|
||||
if !strings.Contains(str, want) {
|
||||
t.Errorf("对象字符串 %q 缺少 %q", str, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(str, "map[") {
|
||||
t.Errorf("仍是 Go 的 map 形态: %q", str)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaValueStringScalars 标量与嵌套结构按 Java 习惯输出。
|
||||
func TestJavaValueStringScalars(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{nil, "null"},
|
||||
{"x", "x"},
|
||||
{true, "true"},
|
||||
{float64(399), "399"},
|
||||
{float64(1.5), "1.5"},
|
||||
{[]any{float64(1), "a"}, "[1, a]"},
|
||||
{map[string]any{"b": float64(1), "a": "x"}, "{a=x, b=1}"},
|
||||
{map[string]any{"n": []any{"x", "y"}}, "{n=[x, y]}"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := javaValueString(c.in); got != c.want {
|
||||
t.Errorf("javaValueString(%#v) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaToURL legado JsURL 的四个属性(并兼容 Java Map 的 searchParams.get)。
|
||||
func TestJavaToURL(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
ar := NewAnalyzeRule()
|
||||
|
||||
check := func(expr, want string) {
|
||||
t.Helper()
|
||||
v, err := r.Run(ar, expr, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s 失败: %v", expr, err)
|
||||
}
|
||||
if got := anyToString(v); got != want {
|
||||
t.Errorf("%s = %q,期望 %q", expr, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').host`, "api.example.com")
|
||||
check(`java.toURL('https://api.example.com:8443/a/b?x=1&y=%E4%B8%AD%E6%96%87').origin`, "https://api.example.com:8443")
|
||||
check(`java.toURL('https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').pathname`, "/a/b")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.x`, "1")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1&y=%E4%B8%AD%E6%96%87').searchParams.y`, "中文")
|
||||
check(`java.toURL('https://api.example.com/a/b?x=1').searchParams.get('x')`, "1")
|
||||
// 相对地址按 baseUrl 解析(对应 Java 的 URL(base, url))
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').origin`, "https://api.example.com")
|
||||
check(`java.toURL('/next?p=2', 'https://api.example.com/a/b').pathname`, "/next")
|
||||
// 不存在的参数给 null(对应 Java Map.get 返回 null)
|
||||
check(`String(java.toURL('https://api.example.com/a').searchParams.get('nope'))`, "null")
|
||||
// 非绝对地址必须报错(Java 抛 MalformedURLException),不能静默给一个空对象
|
||||
if _, err := r.Run(ar, `java.toURL('not-a-url').host`, nil, ""); err == nil {
|
||||
t.Error("非绝对地址应当报错")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:legado 兼容性回归——`Packages.java.util.*` 容器与 `.[*]` 形式的 JSONPath。
|
||||
//
|
||||
// 背景(拷贝系列轻小说源在 MeBox 搜不到/读不了的根因):
|
||||
// 1. 搜索列表规则写成 `results.list.[*]`(legado/Jayway 接受),PaesslerAG 直接
|
||||
// 解析报错返回空;
|
||||
// 2. 目录规则用 `new Packages.java.util.LinkedHashMap()` / `ArrayList()` 拼装,
|
||||
// goja 里没有 Java,整条规则 ReferenceError。
|
||||
|
||||
// TestNormalizeJSONPathDotBracket 点后跟方括号应归一化成合法路径。
|
||||
func TestNormalizeJSONPathDotBracket(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"results.list.[*]": "$.results.list[*]",
|
||||
"$.results.list.[*]": "$.results.list[*]",
|
||||
"$.a.[0].b": "$.a[0].b",
|
||||
"$.a.['k']": "$.a['k']",
|
||||
"$.results.list": "$.results.list",
|
||||
"$.results..list[*]": "$.results..list[*]",
|
||||
"results.list": "$.results.list",
|
||||
"$[0].name": "$[0].name",
|
||||
"..list[*]": "$..list[*]",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeJSONPath(in); got != want {
|
||||
t.Errorf("normalizeJSONPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetElementsDotBracketStarJsonPath 真实书源的 `results.list.[*]` 必须能取到列表。
|
||||
func TestGetElementsDotBracketStarJsonPath(t *testing.T) {
|
||||
const body = `{"results":{"list":[{"name":"甲"},{"name":"乙"}]}}`
|
||||
for _, rule := range []string{"results.list.[*]", "$.results.list", "results.list"} {
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(body, "")
|
||||
els, err := ar.GetElements(rule)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", rule, err)
|
||||
}
|
||||
if len(els) != 2 {
|
||||
t.Fatalf("%s: elements=%d, want 2", rule, len(els))
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "甲" {
|
||||
t.Fatalf("%s: first name=%q err=%v", rule, name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesUtilCollections Packages.java.util 的 List/Map 应能拼出目录列表,
|
||||
// 且导出给引擎的是干净的数组/映射(方法不能混进元素里)。
|
||||
func TestPackagesUtilCollections(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
if err := r.JSLibErr(); err != nil {
|
||||
t.Fatalf("Packages 环境初始化失败: %v", err)
|
||||
}
|
||||
ar := NewAnalyzeRule()
|
||||
ar.SetContent(`{"results":{"list":[{"name":"第一卷","id":7}]}}`, "https://api.example.com/volumes")
|
||||
ar.SetJSRunner(r.ForAnalyzer(ar))
|
||||
|
||||
js := `<js>
|
||||
function mk(n, u) { var m = new Packages.java.util.LinkedHashMap(); m.put('name', n); m.put('url', u); return m; }
|
||||
var base = 'https://api.example.com/volume/';
|
||||
var out = new Packages.java.util.ArrayList();
|
||||
var vols = JSON.parse(result).results.list;
|
||||
for (var i = 0; i < vols.length; i++) { out.add(mk(vols[i].name, base + vols[i].id)); }
|
||||
out;</js>`
|
||||
els, err := ar.GetElements(js)
|
||||
if err != nil {
|
||||
t.Fatalf("GetElements: %v", err)
|
||||
}
|
||||
if len(els) != 1 {
|
||||
t.Fatalf("elements=%d, want 1", len(els))
|
||||
}
|
||||
if m, ok := els[0].(map[string]any); ok {
|
||||
if _, hasMethod := m["put"]; hasMethod {
|
||||
t.Fatalf("容器方法泄漏进了元素: %#v", m)
|
||||
}
|
||||
}
|
||||
name, err := ar.GetString("$.name", els[0], false)
|
||||
if err != nil || name != "第一卷" {
|
||||
t.Fatalf("name=%q err=%v", name, err)
|
||||
}
|
||||
url, _ := ar.GetString("$.url", els[0], false)
|
||||
if url != "https://api.example.com/volume/7" {
|
||||
t.Fatalf("url=%q", url)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPackagesHashMapUsableAsHeaders java.get(url, headers) 的 headers 用
|
||||
// Packages.java.util.HashMap 构造时,导出结果应是干净的键值映射。
|
||||
func TestPackagesHashMapUsableAsHeaders(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
v, err := r.Run(NewAnalyzeRule(), `(function () {
|
||||
var h = new Packages.java.util.HashMap();
|
||||
h.put('X-Test', 'v1');
|
||||
return JSON.stringify(h);
|
||||
})()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s := anyToString(v); s != `{"X-Test":"v1"}` {
|
||||
t.Fatalf("HashMap 导出含额外键: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,17 @@ type Request struct {
|
||||
// 返回而不是解码成文本(对应 legado AnalyzeUrl.type)。
|
||||
// 书源用它配合 data: 地址当参数信封,见 datauri.go。
|
||||
HexBody bool
|
||||
// Raw 请求响应按「原始字节」返回,跳过 charset 解码。
|
||||
// 供 java.downloadFile / java.cacheFile 这类需要保真落盘的调用使用:
|
||||
// 一旦按 UTF-8 解码,GBK 字节会被替换成 U+FFFD,写出来的文件就坏了。
|
||||
Raw bool
|
||||
// BodyJsFn 对应 UrlOption.bodyJs:响应体二次处理(JS 执行闭包)。
|
||||
BodyJsFn func(body string) string
|
||||
// Unsupported 非 nil 表示该请求依赖当前阶段不支持的能力,
|
||||
// 值为对应错误(webView;JS 在接入 runner 后已支持)。
|
||||
Unsupported error
|
||||
// Retry 对应 URL 选项的 retry:可恢复失败时的额外重试次数。
|
||||
Retry *int
|
||||
}
|
||||
|
||||
// ParseAnalyzeUrl 对应 AnalyzeUrl.init:URL 规则 → 可执行请求。
|
||||
@@ -202,6 +208,7 @@ func ParseAnalyzeUrlWithJS(mUrl, key string, page int, baseUrl string, runner *J
|
||||
}
|
||||
}
|
||||
req.Charset = option.Charset
|
||||
req.Retry = option.Retry
|
||||
// 对应 legado AnalyzeUrl.type:值本身不参与判断,只要非空就把响应按
|
||||
// 「原始字节的 hex」返回。书源借此把 data: 地址当参数信封用。
|
||||
if option.Type != "" {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestZhToSimplified 常用繁体字要真的转成简体。
|
||||
// 这是书源里 java.t2s(key) / java.t2s(result) 的语义:阅读 App 会转换,
|
||||
// 不实现时繁体站点既搜不到(关键字没转)也显示不对(标题保持繁体)。
|
||||
func TestZhToSimplified(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"島忍者神龜": "岛忍者神龟",
|
||||
"航海王": "航海王",
|
||||
"我們的書": "我们的书",
|
||||
"學習園地": "学习园地",
|
||||
"": "",
|
||||
"abc123": "abc123", // 非中文原样返回
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToSimplified(in); got != want {
|
||||
t.Errorf("zhToSimplified(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhToTraditional 反向转换。
|
||||
func TestZhToTraditional(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"岛忍者神龟": "島忍者神龜",
|
||||
"我们的书": "我們的書",
|
||||
"学习园地": "學習園地",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := zhToTraditional(in); got != want {
|
||||
t.Errorf("zhToTraditional(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhConvertLeavesPlainTextAlone 不需要转换的文本必须原样返回(含表情/符号)。
|
||||
func TestZhConvertLeavesPlainTextAlone(t *testing.T) {
|
||||
const s = "第1话 ROMANCE DAWN 冒险的序幕 🏴☠️"
|
||||
if got := zhToSimplified(s); got != s {
|
||||
t.Fatalf("zhToSimplified 改动了无需转换的文本: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZhDictParsed 词典要真的解析出来了(防止生成文件被截断)。
|
||||
func TestZhDictParsed(t *testing.T) {
|
||||
zhInit()
|
||||
if len(zhT2S) < 3000 {
|
||||
t.Fatalf("t2s 词典条目 %d,明显偏少", len(zhT2S))
|
||||
}
|
||||
if len(zhS2T) < 3000 {
|
||||
t.Fatalf("s2t 词典条目 %d,明显偏少", len(zhS2T))
|
||||
}
|
||||
// 空格/竖线不属于任何键
|
||||
if _, ok := zhT2S[' ']; ok {
|
||||
t.Fatal("词典里混进了分隔符")
|
||||
}
|
||||
if strings.ContainsRune(zhT2SData, '\uFFFD') {
|
||||
t.Fatal("词典数据含替换符,生成过程有损")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// 中文数字 → 阿拉伯数字,用于 java.toNumChapter。
|
||||
//
|
||||
// 对应 legado 的 `AppPattern.titleNumPattern` + `StringUtils.stringToInt`:把
|
||||
// 「第一百零八章」规整成「第108章」。书源用它统一章节标题(排序/去重)。
|
||||
// 解析不出来时原样返回,不做猜测。
|
||||
var chapterTokenRe = regexp.MustCompile(`[0-9]+|[〇零一二三四五六七八九十百千万两兩]+`)
|
||||
|
||||
var (
|
||||
zhDigits = map[rune]int{'〇': 0, '零': 0, '一': 1, '二': 2, '两': 2, '兩': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9}
|
||||
zhUnits = map[rune]int{'十': 10, '百': 100, '千': 1000}
|
||||
zhBigUnits = map[rune]int{'万': 10000, '億': 100000000, '亿': 100000000}
|
||||
)
|
||||
|
||||
// numChapter 把标题里第一处数字规整成阿拉伯数字。
|
||||
func numChapter(s string) string {
|
||||
loc := chapterTokenRe.FindStringIndex(s)
|
||||
if loc == nil {
|
||||
return s
|
||||
}
|
||||
n, ok := parseCNSNum(s[loc[0]:loc[1]])
|
||||
if !ok {
|
||||
return s
|
||||
}
|
||||
return s[:loc[0]] + strconv.Itoa(n) + s[loc[1]:]
|
||||
}
|
||||
|
||||
// parseCNSNum 解析纯阿拉伯数字或中文数字;无法解析返回 false。
|
||||
func parseCNSNum(tok string) (int, bool) {
|
||||
if tok == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.Atoi(tok); err == nil {
|
||||
return n, true
|
||||
}
|
||||
// 全是数字字符(如「一〇二」这种按位念法)→ 逐位拼起来
|
||||
if digitsOnly(tok) {
|
||||
n := 0
|
||||
for _, r := range tok {
|
||||
n = n*10 + zhDigits[r]
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
total, section, num := 0, 0, 0
|
||||
seen := false
|
||||
for _, r := range tok {
|
||||
if d, ok := zhDigits[r]; ok {
|
||||
num = d
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if u, ok := zhUnits[r]; ok {
|
||||
if num == 0 {
|
||||
num = 1 // 「十二」这类省略了前导一的写法
|
||||
}
|
||||
section += num * u
|
||||
num = 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
if big, ok := zhBigUnits[r]; ok {
|
||||
section += num
|
||||
if section == 0 {
|
||||
section = 1
|
||||
}
|
||||
total += section * big
|
||||
section, num = 0, 0
|
||||
seen = true
|
||||
continue
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if !seen {
|
||||
return 0, false
|
||||
}
|
||||
return total + section + num, true
|
||||
}
|
||||
|
||||
// digitsOnly 判断字符串是否只由中文数字字符组成。
|
||||
func digitsOnly(s string) bool {
|
||||
for _, r := range s {
|
||||
if _, ok := zhDigits[r]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNumChapter 章节标题里的中文/阿拉伯数字要规整成阿拉伯数字。
|
||||
// 书源用 java.toNumChapter 统一标题;缺这个函数时整条规则会抛异常(源直接不可用)。
|
||||
func TestNumChapter(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"第一百零八章": "第108章",
|
||||
"第十二话": "第12话",
|
||||
"第3话": "第3话",
|
||||
"第一话 冒险的序幕": "第1话 冒险的序幕",
|
||||
"一〇二": "102",
|
||||
"两千零一": "2001",
|
||||
"航海王": "航海王", // 没有数字:原样返回
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := numChapter(in); got != want {
|
||||
t.Errorf("numChapter(%q) = %q,期望 %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestJavaExtraHelpers 书源 JS 里会直接调用的几个函数必须存在且可用。
|
||||
func TestJavaExtraHelpers(t *testing.T) {
|
||||
r := NewJSRunner(JSConfig{})
|
||||
|
||||
v, err := r.Run(NewAnalyzeRule(), `java.randomUUID()`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.randomUUID() 失败: %v", err)
|
||||
}
|
||||
id := anyToString(v)
|
||||
if len(id) != 36 || strings.Count(id, "-") != 4 {
|
||||
t.Fatalf("randomUUID 返回值不像 UUID: %q", id)
|
||||
}
|
||||
|
||||
v, err = r.Run(NewAnalyzeRule(), `java.toNumChapter('第一百零八章')`, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("java.toNumChapter() 失败: %v", err)
|
||||
}
|
||||
if got := anyToString(v); got != "第108章" {
|
||||
t.Fatalf("toNumChapter = %q,期望 第108章", got)
|
||||
}
|
||||
|
||||
// 未声明设备能力时 java.deviceID 仍应抛异常(保持「不谎报运行环境」的语义)
|
||||
if _, err := r.Run(NewAnalyzeRule(), `java.deviceID()`, nil, ""); err == nil {
|
||||
t.Fatal("java.deviceID() 应当抛异常")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索分页。
|
||||
//
|
||||
// 搜索页只发第一页时,书源 searchUrl 里的 {{page}} 永远是 1,用户滚到底也拿不到
|
||||
// 后面的结果(表现为「搜索无法翻页,只有第一页」)。所以页码必须由调用方传入并
|
||||
// 原样交给 AnalyzeUrl;非法页码(0、负数)按第 1 页处理,避免拼出 page=0 的地址。
|
||||
|
||||
func pagedSearchSourceJSON(serverURL string) string {
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": "分页源",
|
||||
"searchUrl": serverURL + "/search?q={{key}}&page={{page}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestSearchUsesRequestedPage(t *testing.T) {
|
||||
var gotPage string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPage = r.URL.Query().Get("page")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{"data": []map[string]string{{
|
||||
"book_name": fmt.Sprintf("第%s页的书", gotPage),
|
||||
"author": "作者",
|
||||
"book_url": "/book/" + gotPage,
|
||||
}}}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, pagedSearchSourceJSON(srv.URL))
|
||||
|
||||
for _, tc := range []struct {
|
||||
in int
|
||||
want string
|
||||
}{
|
||||
{1, "1"},
|
||||
{2, "2"},
|
||||
{5, "5"},
|
||||
{0, "1"}, // 非法页码退回第 1 页
|
||||
{-3, "1"}, // 负数同理
|
||||
} {
|
||||
books, skipped, err := svc.Search(t.Context(), "关键词", nil, tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("page=%d 搜索失败: %v", tc.in, err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("page=%d 书源被跳过: %+v", tc.in, skipped)
|
||||
}
|
||||
if len(books) != 1 {
|
||||
t.Fatalf("page=%d 应搜到 1 条,实际 %d 条", tc.in, len(books))
|
||||
}
|
||||
if gotPage != tc.want {
|
||||
t.Fatalf("page=%d 时服务端收到的 page = %q,期望 %q", tc.in, gotPage, tc.want)
|
||||
}
|
||||
if books[0].Name != fmt.Sprintf("第%s页的书", tc.want) {
|
||||
t.Fatalf("page=%d 拿到了别的页的结果: %+v", tc.in, books[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchWithoutPagePlaceholderStillWorks 书源 searchUrl 不带 {{page}} 时
|
||||
// 仍应正常返回首页结果(分页能力是源自己的事,不是搜索的硬要求)。
|
||||
func TestSearchWithoutPagePlaceholderStillWorks(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`{"data":[{"book_name":"单页书","author":"作者","book_url":"/book/1"}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
_ = prepareLoginSource(t, svc, scopeEchoSourceJSON(t, "单页源", srv.URL))
|
||||
|
||||
books, _, err := svc.Search(t.Context(), "书", nil, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if len(books) != 1 || books[0].Name != "单页书" {
|
||||
t.Fatalf("不带 {{page}} 的源在第 3 页也应返回结果: %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -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(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", 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(), "宠魅")
|
||||
books, skipped, err := svc.Search(t.Context(), "宠魅", 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(), "宠魅"); err != nil {
|
||||
if _, _, err := svc.Search(t.Context(), "宠魅", nil, 1); err != nil {
|
||||
t.Fatalf("搜索失败: %v", err)
|
||||
}
|
||||
if gotQuery != "宠魅" {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
|
||||
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
|
||||
|
||||
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
|
||||
t.Helper()
|
||||
src := map[string]any{
|
||||
"bookSourceUrl": serverURL,
|
||||
"bookSourceName": name,
|
||||
"searchUrl": serverURL + "/search?q={{key}}",
|
||||
"ruleSearch": map[string]any{
|
||||
"bookList": "$.data",
|
||||
"name": "$.book_name",
|
||||
"author": "$.author",
|
||||
"bookUrl": "$.book_url",
|
||||
},
|
||||
}
|
||||
out, _ := json.Marshal(src)
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
payload := map[string]any{
|
||||
"data": []map[string]string{{
|
||||
"book_name": bookName,
|
||||
"author": "作者",
|
||||
"book_url": "/book/1",
|
||||
}},
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestSearchRespectsSourceScope(t *testing.T) {
|
||||
srvA := scopeEchoServer(t, "甲书")
|
||||
defer srvA.Close()
|
||||
srvB := scopeEchoServer(t, "乙书")
|
||||
defer srvB.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
for _, raw := range []string{
|
||||
scopeEchoSourceJSON(t, "源A", srvA.URL),
|
||||
scopeEchoSourceJSON(t, "源B", srvB.URL),
|
||||
} {
|
||||
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sources, err := svc.ListSources(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idByName := map[string]string{}
|
||||
for _, s := range sources {
|
||||
idByName[s.Name] = s.ID
|
||||
}
|
||||
|
||||
names := func(books []SearchBook) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, b := range books {
|
||||
out[b.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 指定单个源:只应搜到该源的结果
|
||||
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
t.Fatalf("源A 不应失败: %+v", skipped)
|
||||
}
|
||||
if got := names(books); len(got) != 1 || !got["甲书"] {
|
||||
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 空范围:默认全部启用书源,两个源都应命中
|
||||
books, _, err = svc.Search(t.Context(), "书", nil, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
|
||||
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
|
||||
}
|
||||
|
||||
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
|
||||
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(books); len(got) != 2 {
|
||||
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -51,6 +53,23 @@ func (b *BookSource) HasLogin() bool {
|
||||
return strings.TrimSpace(SPtr(b.LoginURL)) != "" || strings.TrimSpace(SPtr(b.LoginUI)) != ""
|
||||
}
|
||||
|
||||
// SearchCheckKeyWord 书源在搜索规则里声明的校验关键字(legado SearchRule.checkKeyWord)。
|
||||
//
|
||||
// 源的作者用它证明「这个地址返回的确实是正常搜索结果」:校验时搜这个关键字,
|
||||
// 响应里应该出现它;被风控/换域名后响应里就没有了。legado 的 getCheckKeyword
|
||||
// 对含 http / :: / ++ / -- 的值不认(那是地址或扩展标记,不是关键字),这里保持一致。
|
||||
func (b *BookSource) SearchCheckKeyWord() string {
|
||||
if b == nil || b.RuleSearch == nil {
|
||||
return ""
|
||||
}
|
||||
kw := strings.TrimSpace(SPtr(b.RuleSearch.CheckKeyWord))
|
||||
if kw == "" || strings.Contains(kw, "http") || strings.Contains(kw, "::") ||
|
||||
strings.Contains(kw, "++") || strings.Contains(kw, "--") {
|
||||
return ""
|
||||
}
|
||||
return kw
|
||||
}
|
||||
|
||||
// LoginJS 返回 loginUrl 的纯 JS 体(剥掉 @js: / <js>…< 包裹)。
|
||||
// 对应 legado BaseSource.getLoginJs():loginUi 的按钮 action 会拼在其后执行,
|
||||
// 因此 loginUrl 同时充当登录交互的函数库。
|
||||
@@ -60,17 +79,26 @@ func (b *BookSource) LoginJS() string {
|
||||
|
||||
// stripJSWrapper 去掉 JS 规则的 @js: / <js>…</js> 包裹。
|
||||
func stripJSWrapper(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "@js:") {
|
||||
return s[len("@js:"):]
|
||||
out, _ := stripJSWrapperOK(s)
|
||||
return out
|
||||
}
|
||||
|
||||
// stripJSWrapperOK 与 stripJSWrapper 相同,但额外告知是否命中 JS 包装。
|
||||
// 「可能是 JSON 也可能是 JS 规则」的字段(如书源 header)需要区分这两者。
|
||||
func stripJSWrapperOK(s string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
lower := strings.ToLower(trimmed)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "@js:"):
|
||||
return trimmed[len("@js:"):], true
|
||||
case strings.HasPrefix(lower, "<js>"):
|
||||
body := trimmed[len("<js>"):]
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "</js>")
|
||||
body = strings.TrimSuffix(strings.TrimSpace(body), "<")
|
||||
return body, true
|
||||
default:
|
||||
return trimmed, false
|
||||
}
|
||||
if strings.HasPrefix(s, "<js>") {
|
||||
s = s[len("<js>"):]
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "</js>")
|
||||
s = strings.TrimSuffix(strings.TrimSpace(s), "<")
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SearchRule 搜索规则。
|
||||
@@ -110,6 +138,9 @@ type TocRule struct {
|
||||
ChapterURL *string `json:"chapterUrl"`
|
||||
IsVolume *string `json:"isVolume"`
|
||||
UpdateTime *string `json:"updateTime"`
|
||||
// NextTocURL 下一页目录规则(可为多值)。长书目录按 offset/limit 分页时靠它
|
||||
// 取全,缺了就只能拿到第一页(如 399 章只出现 100 章)。
|
||||
NextTocURL *string `json:"nextTocUrl"`
|
||||
}
|
||||
|
||||
// ContentRule 正文规则。
|
||||
@@ -125,25 +156,33 @@ type ContentRule struct {
|
||||
|
||||
// ExploreRule 发现规则。
|
||||
type ExploreRule struct {
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
BookList *string `json:"bookList"`
|
||||
Name *string `json:"name"`
|
||||
Author *string `json:"author"`
|
||||
Kind *string `json:"kind"`
|
||||
WordCount *string `json:"wordCount"`
|
||||
LastChapter *string `json:"lastChapter"`
|
||||
Intro *string `json:"intro"`
|
||||
CoverURL *string `json:"coverUrl"`
|
||||
BookURL *string `json:"bookUrl"`
|
||||
ExploreURL *string `json:"exploreUrl"`
|
||||
ExploreKinds *string `json:"exploreKinds"`
|
||||
CheckKeyWord *string `json:"checkKeyWord"`
|
||||
}
|
||||
|
||||
// ParseBookSource 将书源 JSON 解析为结构体。
|
||||
//
|
||||
// 先按标准 JSON 解一次;失败时用「宽容版」再解一次(见 normalizeSourceJSON)。
|
||||
// 阅读生态里的导出工具写法五花八门,同一份书源在别处能用、在这里却整体导入失败,
|
||||
// 只因为某个字段的类型变了形状 —— 这类差异不值得让用户改 JSON。
|
||||
func ParseBookSource(raw string) (*BookSource, error) {
|
||||
var bs BookSource
|
||||
if err := json.Unmarshal([]byte(raw), &bs); err != nil {
|
||||
return nil, err
|
||||
var relaxed BookSource
|
||||
if err2 := json.Unmarshal([]byte(normalizeSourceJSON(raw)), &relaxed); err2 != nil {
|
||||
return nil, err // 报原始错误:字段位置更贴近用户看到的 JSON
|
||||
}
|
||||
bs = relaxed
|
||||
}
|
||||
bs.RawJSON = raw
|
||||
if bs.RawVariables != nil && strings.TrimSpace(*bs.RawVariables) != "" {
|
||||
@@ -152,6 +191,71 @@ func ParseBookSource(raw string) (*BookSource, error) {
|
||||
return &bs, nil
|
||||
}
|
||||
|
||||
// sourceNumericFields 是书源里可能被写成字符串的数字字段。
|
||||
// 例:{"lastUpdateTime":"1788449879889"}(阅读本体导出/第三方源站的常见写法)。
|
||||
var sourceNumericFields = []string{
|
||||
"customOrder", "weight", "bookSourceType", "lastUpdateTime", "respondTime",
|
||||
}
|
||||
|
||||
// sourceRuleObjectFields 在 legado 里是对象,但部分导出工具把空规则写成 []。
|
||||
// 例:{"ruleExplore":[]}(空发现规则)。
|
||||
var sourceRuleObjectFields = []string{
|
||||
"ruleExplore", "ruleSearch", "ruleBookInfo", "ruleToc", "ruleContent",
|
||||
}
|
||||
|
||||
// normalizeSourceJSON 消化书源 JSON 与 legado 实体之间的形状差异:
|
||||
// - 数字字段被写成字符串 → 转成数字;
|
||||
// - 规则对象被写成空数组 [] → 转成空对象 {}。
|
||||
//
|
||||
// 只动顶层已知字段,规则 JS 字符串原样保留。解析不出来就原样返回,交给调用方报错。
|
||||
func normalizeSourceJSON(raw string) string {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err != nil {
|
||||
return raw
|
||||
}
|
||||
changed := false
|
||||
for _, key := range sourceNumericFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var text string
|
||||
if json.Unmarshal(value, &text) != nil {
|
||||
continue // 本来就是数字(或其它类型):不掺和
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
// 空字符串当成「没有这个字段」,否则会报类型错误
|
||||
delete(fields, key)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if _, err := strconv.ParseInt(text, 10, 64); err != nil {
|
||||
continue // 不是整数(如 "1.0"):不猜,让标准解析去报错
|
||||
}
|
||||
fields[key] = json.RawMessage(text)
|
||||
changed = true
|
||||
}
|
||||
for _, key := range sourceRuleObjectFields {
|
||||
value, ok := fields[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if bytes.Equal(bytes.TrimSpace(value), []byte("[]")) {
|
||||
fields[key] = json.RawMessage("{}")
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// SourceProps 书源 JSON 原样转为 map(注入 JS 的 `source` 对象)。
|
||||
func (b *BookSource) SourceProps() map[string]any {
|
||||
if b.RawJSON == "" {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package reader
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestRawSourceNeedsBrowser webView/webjs 类书源要在列表里先标出来
|
||||
// (服务端没有无头浏览器,这类源注定跑不通,不该等用户点进去才报错)。
|
||||
func TestRawSourceNeedsBrowser(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
want bool
|
||||
}{
|
||||
{"webView 选项", `{"searchUrl":"https://a.com/s,{webView:true}"}`, true},
|
||||
{"webjs 规则", `{"ruleContent":{"content":"@webjs:getDom()"}}`, true},
|
||||
{"规则里提到 webJs", `{"ruleToc":{"chapterList":"<js>if(supportWebJs){}</js>"}}`, true},
|
||||
{"普通源", `{"searchUrl":"https://a.com/s?q={{key}}","ruleSearch":{"bookList":"$.list[*]"}}`, false},
|
||||
{"空", ``, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := rawSourceNeedsBrowser(c.raw); got != c.want {
|
||||
t.Errorf("%s: rawSourceNeedsBrowser = %v,期望 %v", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
)
|
||||
|
||||
// TestApplyHeadersEvaluatesJSHeader 覆盖拷贝漫画(优++)这类书源:
|
||||
// header 是 @js: 规则,运行时才用 baseUrl 拼出 platform/version/referer。
|
||||
// 此前 applyHeaders 只做 json.Unmarshal,JS 形态被整体丢掉,请求少了这些
|
||||
// 必需头,上游同样回 200 但结果是空列表 —— 表现为「源能导入却搜不到内容」。
|
||||
func TestApplyHeadersEvaluatesJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nJSON.stringify({\"platform\":\"1\",\"version\":\"9.9.9\",\"referer\":baseUrl})"
|
||||
src := &model.ReaderBookSource{
|
||||
Name: "拷贝漫画(优++)",
|
||||
SourceURL: "https://www.mangacopy.com/",
|
||||
Header: header,
|
||||
}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, BookSourceName: src.Name, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://api.mangacopy.com/api/v3/search/comic", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("火影", 1))
|
||||
|
||||
if got := req.Headers["platform"]; got != "1" {
|
||||
t.Fatalf("platform = %q,期望 \"1\"(JS 形态的 header 没有被执行)", got)
|
||||
}
|
||||
if got := req.Headers["version"]; got != "9.9.9" {
|
||||
t.Fatalf("version = %q,期望 \"9.9.9\"", got)
|
||||
}
|
||||
// baseUrl 必须绑成书源地址(对应 legado getHeaderMap 里的 getKey())
|
||||
if got := req.Headers["referer"]; got != "https://www.mangacopy.com/" {
|
||||
t.Fatalf("referer = %q,期望书源地址", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersKeepsPlainJSONHeader 直接写 JSON 的 header 不能回归。
|
||||
func TestApplyHeadersKeepsPlainJSONHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
header := `{"User-Agent":"MyBox/1.0","X-Token":"abc"}`
|
||||
src := &model.ReaderBookSource{Name: "纯 JSON 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if got := req.Headers["User-Agent"]; got != "MyBox/1.0" {
|
||||
t.Fatalf("User-Agent = %q", got)
|
||||
}
|
||||
if got := req.Headers["X-Token"]; got != "abc" {
|
||||
t.Fatalf("X-Token = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyHeadersSurvivesBrokenJSHeader 坏掉的 header JS 不能 panic,
|
||||
// 也不该把半成品请求头发出去。
|
||||
func TestApplyHeadersSurvivesBrokenJSHeader(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
const header = "@js:\nthrow new Error('boom')"
|
||||
src := &model.ReaderBookSource{Name: "坏 header 源", SourceURL: "https://example.com/", Header: header}
|
||||
bs := &BookSource{BookSourceURL: src.SourceURL, Header: strPtr(header)}
|
||||
|
||||
sess := svc.newSession(t.Context(), src, bs)
|
||||
defer sess.close()
|
||||
|
||||
req := &rule.Request{Method: "GET", URL: "https://example.com/search", Headers: map[string]string{}}
|
||||
sess.applyHeaders(req, sess.runner("", 0))
|
||||
|
||||
if len(req.Headers) != 0 {
|
||||
t.Fatalf("求值失败的 header 不应写入任何头,实际: %v", req.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
@@ -0,0 +1,149 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// messySourceJSON 复刻真实「拷贝漫画(优++)」源文件的形状:
|
||||
// lastUpdateTime 是字符串、respondTime 是数字、ruleExplore 是空数组、
|
||||
// ruleBookInfo/ruleContent 里同时有数字与 JS 字符串。
|
||||
// 这类文件在阅读 App 里能导入,在这里曾经整体解析失败。
|
||||
const messySourceJSON = `[{
|
||||
"bookSourceComment": "//By情无羁25.04.09 使用需要魔法",
|
||||
"bookSourceGroup": "",
|
||||
"bookSourceName": "拷贝漫画(优++)",
|
||||
"bookSourceType": 0,
|
||||
"bookSourceUrl": "https://www.mangacopy.com/",
|
||||
"customOrder": 208,
|
||||
"enabled": true,
|
||||
"enabledCookieJar": false,
|
||||
"enabledExplore": true,
|
||||
"exploreUrl": "@js:\nsort = [];\nJSON.stringify(sort)",
|
||||
"header": "@js:\nJSON.stringify({\"platform\":\"1\",\"referer\":baseUrl})",
|
||||
"lastUpdateTime": "1788449879889",
|
||||
"respondTime": 181130,
|
||||
"ruleBookInfo": {"init": "$..comic", "intro": "$..brief"},
|
||||
"ruleContent": {"content": "$..url\n<js>result</js>", "imageStyle": "FULL"},
|
||||
"ruleExplore": [],
|
||||
"ruleSearch": {"bookList": "$..list[*]", "name": "$.name"},
|
||||
"ruleToc": {"chapterList": "$..list[*]", "chapterName": "name"},
|
||||
"searchUrl": "https://api.mangacopy.com/api/v3/search/comic?q={{key}}",
|
||||
"weight": 0
|
||||
}]`
|
||||
|
||||
// TestImportSourcesAcceptsMessySource 覆盖用户的真实场景:数字写法的字符串字段
|
||||
// (lastUpdateTime)与空数组写法的规则对象(ruleExplore)不该让整条书源导入失败。
|
||||
func TestImportSourcesAcceptsMessySource(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
|
||||
imported, err := svc.ImportSources(ctx, messySourceJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("导入失败: %v", err)
|
||||
}
|
||||
if imported != 1 {
|
||||
t.Fatalf("导入数量 = %d,期望 1", imported)
|
||||
}
|
||||
|
||||
bs, err := svc.repo.GetSourceByURL(ctx, "https://www.mangacopy.com/")
|
||||
if err != nil || bs == nil {
|
||||
t.Fatalf("按 URL 查不到导入的书源: %v", err)
|
||||
}
|
||||
if bs.Name != "拷贝漫画(优++)" {
|
||||
t.Fatalf("书源名 = %q", bs.Name)
|
||||
}
|
||||
// 关键字段要真的解出来,而不是「解成功了但字段全空」
|
||||
parsed, err := ParseBookSource(bs.RawJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("重新解析失败: %v", err)
|
||||
}
|
||||
if parsed.LastUpdateTime == nil || *parsed.LastUpdateTime != 1788449879889 {
|
||||
t.Fatalf("lastUpdateTime = %v,期望 1788449879889", parsed.LastUpdateTime)
|
||||
}
|
||||
if parsed.RuleExplore == nil {
|
||||
t.Fatal("ruleExplore 应当被当成空规则对象,而不是解析失败")
|
||||
}
|
||||
if SPtr(parsed.RuleSearch.BookList) != "$..list[*]" {
|
||||
t.Fatalf("ruleSearch.bookList = %q", SPtr(parsed.RuleSearch.BookList))
|
||||
}
|
||||
if SPtr(parsed.RuleContent.ImageStyle) != "FULL" {
|
||||
t.Fatalf("ruleContent.imageStyle = %q", SPtr(parsed.RuleContent.ImageStyle))
|
||||
}
|
||||
// 落库的 raw 必须是原文(源 JS 可能依赖原样字段),不能被改写。
|
||||
// 这里特意按原文里的「冒号后有空格」写法比对:一旦被重新序列化就对不上了。
|
||||
if !strings.Contains(bs.RawJSON, `"lastUpdateTime": "1788449879889"`) {
|
||||
t.Fatalf("RawJSON 被改写了,应当保留原始文本;实际: %s", bs.RawJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeSourceJSON 单独锁定宽容规则本身。
|
||||
func TestNormalizeSourceJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string // 期望解析后可用的字段值 / 是否报错
|
||||
}{
|
||||
{"数字字符串", `{"weight":"12"}`, "12"},
|
||||
{"已经是数字", `{"weight":12}`, "12"},
|
||||
{"空字符串当缺省", `{"weight":""}`, "<nil>"},
|
||||
{"非整数不猜", `{"weight":"1.5"}`, "<err>"},
|
||||
{"空数组规则", `{"ruleExplore":[]}`, "obj"},
|
||||
{"正常规则不受影响", `{"ruleExplore":{"bookList":"a"}}`, "obj"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeSourceJSON(c.in)
|
||||
switch c.want {
|
||||
case "obj":
|
||||
if !strings.Contains(got, "{}") && !strings.Contains(got, `"bookList":"a"`) {
|
||||
t.Errorf("%s: normalizeSourceJSON(%s) = %s", c.name, c.in, got)
|
||||
}
|
||||
case "<nil>":
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight != nil {
|
||||
t.Errorf("%s: weight 应当为 nil,实际 %v", c.name, *bs.Weight)
|
||||
}
|
||||
case "<err>":
|
||||
if _, err := ParseBookSource(got); err == nil {
|
||||
t.Errorf("%s: 期望解析报错(不做无根据的猜测)", c.name)
|
||||
}
|
||||
default:
|
||||
bs, err := ParseBookSource(got)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if bs.Weight == nil || *bs.Weight != 12 {
|
||||
t.Errorf("%s: weight = %v,期望 12", c.name, bs.Weight)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseBookSourceRejectsGarbage 真的坏掉的 JSON 仍要报错,不能因为宽容而静默吞掉。
|
||||
func TestParseBookSourceRejectsGarbage(t *testing.T) {
|
||||
if _, err := ParseBookSource(`{"bookSourceUrl":123}`); err == nil {
|
||||
t.Fatal("bookSourceUrl 是数字时应当报错")
|
||||
}
|
||||
if _, err := ParseBookSource(`{not json`); err == nil {
|
||||
t.Fatal("非法 JSON 应当报错")
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportSourcesReportsReasonWhenNothingImported 全部失败时必须把原因带回前端,
|
||||
// 否则界面只会弹「成功导入 0 个书源」,用户无从下手。
|
||||
func TestImportSourcesReportsReasonWhenNothingImported(t *testing.T) {
|
||||
svc, _ := newLoginTestService(t)
|
||||
|
||||
_, err := svc.ImportSources(t.Context(), `[{"bookSourceUrl":123,"bookSourceName":"坏源"}]`)
|
||||
if err == nil {
|
||||
t.Fatal("一条都没导入时应当返回错误")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "解析失败") {
|
||||
t.Fatalf("错误信息应说明是解析失败,实际: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/service/reader/rule"
|
||||
@@ -23,8 +24,12 @@ type sourceState struct {
|
||||
variable string
|
||||
loginInfo string
|
||||
loginHeader string
|
||||
cookies map[string]string // domain → "k=v; k=v"
|
||||
loaded bool
|
||||
// cookies / cookieMu:domain → "k=v; k=v"。
|
||||
// cookieMu 保护这张表:浏览器面板里的多个资源/接口代理请求会并发回写
|
||||
// Cookie(见 browser_panel.go),而书源 JS 同时在读,不加锁会 data race。
|
||||
cookies map[string]string
|
||||
cookieMu sync.Mutex
|
||||
loaded bool
|
||||
|
||||
toasts []string
|
||||
browsers []rule.BrowserRequest
|
||||
@@ -119,6 +124,13 @@ func (st *sourceState) SetLoginHeader(v string) {
|
||||
func (st *sourceState) GetCookie(rawURL string) string { return st.GetCookieKey(rawURL, "") }
|
||||
|
||||
func (st *sourceState) GetCookieKey(rawURL, key string) string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
return st.getCookieKeyLocked(rawURL, key)
|
||||
}
|
||||
|
||||
// getCookieKeyLocked 的调用方需持有 cookieMu。
|
||||
func (st *sourceState) getCookieKeyLocked(rawURL, key string) string {
|
||||
domain := rule.CookieDomain(rawURL)
|
||||
if domain == "" {
|
||||
return ""
|
||||
@@ -138,6 +150,8 @@ func (st *sourceState) SetCookie(rawURL, cookie string) {
|
||||
if domain == "" || strings.TrimSpace(cookie) == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
merged := rule.MergeCookie(st.cookies[domain], cookie)
|
||||
if merged == st.cookies[domain] {
|
||||
return
|
||||
@@ -151,6 +165,8 @@ func (st *sourceState) RemoveCookie(rawURL string) {
|
||||
if domain == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if _, ok := st.cookies[domain]; !ok {
|
||||
return
|
||||
}
|
||||
@@ -197,12 +213,14 @@ func (st *sourceState) flush() {
|
||||
if !st.dirty || st.svc == nil || st.svc.repo == nil || st.sourceURL == "" {
|
||||
return
|
||||
}
|
||||
st.cookieMu.Lock()
|
||||
cookiesJSON := ""
|
||||
if len(st.cookies) > 0 {
|
||||
if b, err := json.Marshal(st.cookies); err == nil {
|
||||
cookiesJSON = string(b)
|
||||
}
|
||||
}
|
||||
st.cookieMu.Unlock()
|
||||
rec := &model.ReaderSourceState{
|
||||
SourceURL: st.sourceURL,
|
||||
Variable: st.variable,
|
||||
@@ -221,6 +239,8 @@ func (st *sourceState) flush() {
|
||||
|
||||
// snapshotCookies 返回 Cookie 副本(domain → cookie 串)。
|
||||
func (st *sourceState) snapshotCookies() map[string]string {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
out := make(map[string]string, len(st.cookies))
|
||||
for d, c := range st.cookies {
|
||||
out[d] = c
|
||||
@@ -230,6 +250,8 @@ func (st *sourceState) snapshotCookies() map[string]string {
|
||||
|
||||
// clearCookies 清空全部 Cookie 并标记待落库(对应 legado removeAllCookies)。
|
||||
func (st *sourceState) clearCookies() {
|
||||
st.cookieMu.Lock()
|
||||
defer st.cookieMu.Unlock()
|
||||
if len(st.cookies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// tocHitCounter 统计目录地址被上游请求的次数(目录抓取去重的回归用)。
|
||||
type tocHitCounter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]int
|
||||
}
|
||||
|
||||
func (c *tocHitCounter) add(path string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.hits == nil {
|
||||
c.hits = map[string]int{}
|
||||
}
|
||||
c.hits[path]++
|
||||
}
|
||||
|
||||
func (c *tocHitCounter) count(path string) int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.hits[path]
|
||||
}
|
||||
|
||||
// TestGetTocConcurrentCallsHitUpstreamOnce 同一本书的并发抓目录只打一次上游。
|
||||
//
|
||||
// 回归:换源、加入书架之后,服务端会预热目录(WarmUpBookChaptersAsync),阅读页在
|
||||
// 章节缓存为空时又会自己抓一次 /api/reader/toc,两次几乎同时到达——实测同一份目录
|
||||
// 被上游抓了两遍(3.7s 与 11.7s),章节也被写了两遍。
|
||||
func TestGetTocConcurrentCallsHitUpstreamOnce(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := 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"):
|
||||
counter.add(r.URL.Path)
|
||||
// 抓取期间让后到的调用方一定落在同一次在飞请求上。
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
|
||||
tocURL := srv.URL + "/book/1/toc.html"
|
||||
|
||||
// 第 0 个调用方按 source_id 找源(阅读页/详情页的形态),其余按 source_url 找
|
||||
// (服务端预热传的形态):两条入口都要落在同一次在飞请求上。
|
||||
const callers = 3
|
||||
var wg sync.WaitGroup
|
||||
chapters := make([][]TocChapter, callers)
|
||||
errs := make([]error, callers)
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
id := ""
|
||||
if i == 0 {
|
||||
id = sourceID
|
||||
}
|
||||
chapters[i], errs[i] = svc.GetToc(context.Background(), "u1", id, srv.URL, book.BookURL, tocURL)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < callers; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, errs[i])
|
||||
}
|
||||
if len(chapters[i]) != 2 {
|
||||
t.Fatalf("第 %d 个调用方拿到 %d 章,期望 2", i, len(chapters[i]))
|
||||
}
|
||||
}
|
||||
if n := counter.count("/book/1/toc.html"); n != 1 {
|
||||
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget 预热传空 toc_url、阅读页传
|
||||
// book_url(前端 `toc_url || book_url` 的兜底)指的是同一份目录,必须落在同一次抓取上。
|
||||
func TestGetTocDedupeTreatsEmptyAndBookURLAsSameTarget(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := 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"):
|
||||
counter.add(r.URL.Path)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL) // TocURL 留空,模拟还没写回目录地址
|
||||
if book.TocURL != "" {
|
||||
t.Fatalf("前置条件不成立:测试书的 toc_url 应为空,实际 %q", book.TocURL)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, 2)
|
||||
start := make(chan struct{})
|
||||
for i, tocURL := range []string{"", book.BookURL} { // 预热形态 / 阅读页兜底形态
|
||||
wg.Add(1)
|
||||
go func(i int, tocURL string) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[i] = svc.GetToc(context.Background(), "u1", "", srv.URL, book.BookURL, tocURL)
|
||||
}(i, tocURL)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 个调用方抓目录失败: %v", i, err)
|
||||
}
|
||||
}
|
||||
if n := counter.count("/book/1/toc.html"); n != 1 {
|
||||
t.Fatalf("上游目录被请求 %d 次,期望合并成 1 次", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocDedupeKeepsDifferentBooksSeparate 不同的书不能被合并成一次抓取:
|
||||
// 单飞的 key 里必须带上书本地址。
|
||||
func TestGetTocDedupeKeepsDifferentBooksSeparate(t *testing.T) {
|
||||
counter := &tocHitCounter{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/toc.html"):
|
||||
counter.add(r.URL.Path)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(e2eTocHTML))
|
||||
case strings.HasPrefix(r.URL.Path, "/book/"):
|
||||
_, _ = w.Write([]byte(e2eBookInfoHTML))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book1 := newTocFallbackBook(t, svc, srv.URL) // BookURL = srv.URL + "/book/1"
|
||||
book2 := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: srv.URL,
|
||||
OriginName: "测试源",
|
||||
BookURL: srv.URL + "/book/2",
|
||||
Name: "第二本",
|
||||
}
|
||||
if err := svc.repo.CreateBook(t.Context(), book2); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
|
||||
targets := []struct{ bookURL, tocURL string }{
|
||||
{book1.BookURL, srv.URL + "/book/1/toc.html"},
|
||||
{book2.BookURL, srv.URL + "/book/2/toc.html"},
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, len(targets))
|
||||
start := make(chan struct{})
|
||||
for i, tg := range targets {
|
||||
wg.Add(1)
|
||||
go func(i int, tg struct{ bookURL, tocURL string }) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
_, errs[i] = svc.GetToc(context.Background(), "u1", sourceID, srv.URL, tg.bookURL, tg.tocURL)
|
||||
}(i, tg)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i, err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("第 %d 本书抓目录失败: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/book/1/toc.html", "/book/2/toc.html"} {
|
||||
if n := counter.count(path); n != 1 {
|
||||
t.Fatalf("%s 被请求 %d 次,期望 1 次", path, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// TestGetTocFollowsNextTocUrl 长书目录按 offset/limit 分页时必须翻页取全。
|
||||
// 这是「目录少章」的根因:nextTocUrl 没实现时只会拿到第一页
|
||||
// (实测某漫画站点 total=399/limit=100,MeBox 只得到 100 章)。
|
||||
func TestGetTocFollowsNextTocUrl(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"count":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"2": `{"count":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源(拷贝漫画)一致的写法:用 baseUrl 拼下一页地址
|
||||
nextTocURL := `<js>baseUrl.indexOf('offset=0') >= 0 ? baseUrl.replace('offset=0','offset=2') : ''</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "分页目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
tocURL := srv.URL + "/toc?limit=2&offset=0"
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", tocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第二页没有被抓取)", len(chapters))
|
||||
}
|
||||
for i, want := range []string{"第1话", "第2话", "第3话", "第4话"} {
|
||||
if chapters[i].Title != want {
|
||||
t.Errorf("章节[%d] = %q,期望 %q", i, chapters[i].Title, want)
|
||||
}
|
||||
if chapters[i].Index != i {
|
||||
t.Errorf("章节[%d] 的 Index = %d,应按翻页顺序重排", i, chapters[i].Index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlListsAllPagesFromFirstPage 书源只在第一页能算出完整后续页列表
|
||||
// (拷贝漫画的写法:规则锚定 offset=0,第二页上算出来还是同一批地址)。
|
||||
// 这种源必须把所有后续页一次性入队,否则只翻一页就停。
|
||||
func TestGetTocNextTocUrlListsAllPagesFromFirstPage(t *testing.T) {
|
||||
pages := map[string]string{
|
||||
"0": `{"total":4,"list":[{"name":"第1话","url":"/c/1"},{"name":"第2话","url":"/c/2"}]}`,
|
||||
"100": `{"total":4,"list":[{"name":"第3话","url":"/c/3"},{"name":"第4话","url":"/c/4"}]}`,
|
||||
}
|
||||
var hits []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
offset := r.URL.Query().Get("offset")
|
||||
hits = append(hits, offset)
|
||||
body, ok := pages[offset]
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"total":4,"list":[]}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 与真实书源一致:一次列出全部后续页,且锚点写死 offset=0(在第二页上等于原地踏步)
|
||||
nextTocURL := `<js>
|
||||
list=[];
|
||||
for(i=0;i<=1;i++){list.push(baseUrl.replace('offset=0','offset='+100*i))}
|
||||
list
|
||||
</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "全量下一页测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc?limit=2&offset=0")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 4 {
|
||||
t.Fatalf("章节数 = %d,期望 4(第一页列出的后续页没有被全部抓取)", len(chapters))
|
||||
}
|
||||
// 每页只抓一次(队列去重)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("上游请求次数 = %d(%v),期望 2", len(hits), hits)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetTocNextTocUrlSelfReferenceStops nextTocUrl 指回当前页时必须停下,
|
||||
// 不能靠「规则永远给下一页」把自己转死。
|
||||
func TestGetTocNextTocUrlSelfReferenceStops(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"list":[{"name":"第1话","url":"/c/1"}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// 永远返回同一页地址(用 baseUrl 原样返回)
|
||||
nextTocURL := `<js>baseUrl</js>`
|
||||
header := ""
|
||||
src := &model.ReaderBookSource{Name: "自引用目录测试源", SourceURL: srv.URL}
|
||||
bs := &BookSource{
|
||||
BookSourceURL: srv.URL,
|
||||
Header: &header,
|
||||
RuleToc: &TocRule{
|
||||
ChapterList: strPtr("$.list[*]"),
|
||||
ChapterName: strPtr("$.name"),
|
||||
ChapterURL: strPtr("$.url"),
|
||||
NextTocURL: strPtr(nextTocURL),
|
||||
},
|
||||
}
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
chapters, _, err := svc.getTocFrom(t.Context(), src, bs, srv.URL+"/book/1", srv.URL+"/toc")
|
||||
if err != nil {
|
||||
t.Fatalf("取目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 1 {
|
||||
t.Fatalf("章节数 = %d,期望 1", len(chapters))
|
||||
}
|
||||
if hits != 1 {
|
||||
t.Fatalf("上游被请求 %d 次,自引用时应只请求 1 次", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package reader
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// newTocFallbackBook 建一本书架记录:只存 book_url,toc_url 留空,
|
||||
// 模拟聚合类书源加入书架时的形态。
|
||||
func newTocFallbackBook(t *testing.T, svc *ReaderService, serverURL string) *model.ReaderBook {
|
||||
t.Helper()
|
||||
book := &model.ReaderBook{
|
||||
UserID: "u1",
|
||||
Origin: serverURL,
|
||||
OriginName: "测试源",
|
||||
BookURL: serverURL + "/book/1",
|
||||
Name: "斗破苍穹",
|
||||
}
|
||||
if err := svc.repo.CreateBook(t.Context(), book); err != nil {
|
||||
t.Fatalf("创建书籍失败: %v", err)
|
||||
}
|
||||
return book
|
||||
}
|
||||
|
||||
// 聚合类书源(光遇聚合一类)把目录地址放在详情结果里,加书架时只存了 book_url。
|
||||
// 调用方又习惯把 book_url 当目录地址兜底(前端 toc_url || book_url),于是目录规则
|
||||
// 拿到的是书籍详情页 → 0 章 → 阅读页显示「目录为空」,漫画根本进不去。
|
||||
//
|
||||
// GetToc 必须自己补一次详情、用详情里的 tocUrl 重抓,并把地址写回书架。
|
||||
func TestGetTocFallsBackToBookInfoTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, "")
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应回退到详情里的 tocUrl 抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
|
||||
// 目录地址要写回书架:之后书架/阅读页/更新目录都不用再走一次详情
|
||||
stored, err := svc.repo.GetBook(ctx, book.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(stored.TocURL, "/book/1/toc") {
|
||||
t.Fatalf("目录地址未写回: %q", stored.TocURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 前端会把空 toc_url 兜底成 book_url 传进来,这条路径同样要能拿到目录。
|
||||
func TestGetTocTreatsBookURLAsMissingTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.BookURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("book_url 兜底的目录地址也应回退到详情,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
|
||||
// 正常源(详情给了独立目录页、调用方也传了)不能被这次改动影响。
|
||||
func TestGetTocKeepsGivenTocURL(t *testing.T) {
|
||||
srv := e2eServer()
|
||||
defer srv.Close()
|
||||
|
||||
svc, _ := newLoginTestService(t)
|
||||
ctx := t.Context()
|
||||
sourceID := importTestSource(t, svc, e2eSourceJSON(srv.URL), srv.URL)
|
||||
book := newTocFallbackBook(t, svc, srv.URL)
|
||||
book.TocURL = srv.URL + "/book/1/toc.html"
|
||||
if err := svc.repo.UpdateBook(ctx, book); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
chapters, err := svc.GetToc(ctx, "u1", sourceID, srv.URL, book.BookURL, book.TocURL)
|
||||
if err != nil {
|
||||
t.Fatalf("抓目录失败: %v", err)
|
||||
}
|
||||
if len(chapters) != 2 {
|
||||
t.Fatalf("应正常抓到 2 章,实际 %d 章", len(chapters))
|
||||
}
|
||||
}
|
||||
@@ -237,6 +237,15 @@ func (b *serviceContainerBuilder) initImageProxy() {
|
||||
b.c.ImageProxy.SetAllowedRemoteHostsProvider(func() []string {
|
||||
return b.c.EmbyRemote.ConfiguredRemoteHosts(context.Background())
|
||||
})
|
||||
// 远程 Emby 的图片 URL 会带着签发时的 api_key 长期缓存/下发;轮换后
|
||||
// URL 里的旧值必然过期。回源时改用账号当前 token,并在上游 401/403 时
|
||||
// 重新登录一次再重试,封面图不再依赖人工改账号。
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthProvider(func(ctx context.Context, host string) (string, bool) {
|
||||
return b.c.EmbyRemote.RemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
b.c.ImageProxy.SetRemoteEmbyAuthRefresher(func(ctx context.Context, host string) (string, error) {
|
||||
return b.c.EmbyRemote.RefreshRemoteEmbyImageTokenForHost(ctx, host)
|
||||
})
|
||||
}
|
||||
b.c.Scan.SetImageProxy(b.c.ImageProxy)
|
||||
b.c.Scraper.SetImageProxy(b.c.ImageProxy)
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Package service — refresh token 轮换的并发保护与幂等复用。
|
||||
//
|
||||
// 背景:refresh token 是一次性凭证,刷新成功后旧 token 立即作废。但真实
|
||||
// 客户端天然会并发使用同一个令牌:
|
||||
//
|
||||
// - 同一个标签页里,WebSocket 重连与 axios 401 拦截器会各发一次刷新;
|
||||
// - 多个标签页共享同一份 localStorage 里的 refresh token;
|
||||
// - 容器重启/部署后,多个页面同时重新加载并刷新。
|
||||
//
|
||||
// 此前第二个请求必然收到 401(revoked),前端把 401 当成会话彻底失效,
|
||||
// 清掉本地令牌并跳登录页——外部表现就是「每次部署之后都要重新登录」。
|
||||
//
|
||||
// 本文件提供两层保护:
|
||||
//
|
||||
// 1. refreshFlights:同一个 token 的并发刷新共享同一次轮换结果;
|
||||
// 2. rotations:轮换后的宽限期内重复提交同一个旧 token,返回同一次轮换
|
||||
// 产生的令牌对(幂等),超过宽限期仍按已撤销处理。
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// refreshTokenReuseGrace 是 refresh token 轮换后的幂等复用宽限期。
|
||||
//
|
||||
// 取值只需覆盖「并发请求 + 客户端一次重试」的量级,越短越好:宽限期内
|
||||
// 持有旧 token 的一方仍能换取同一个会话,超过则按 token 复用检测处理。
|
||||
const refreshTokenReuseGrace = 60 * time.Second
|
||||
|
||||
// rotatedRefreshToken 保存一次轮换产生的令牌对,供宽限期内幂等复用。
|
||||
//
|
||||
// 这里必须保留 refresh token 明文——客户端要用它继续会话,而哈希无法反推。
|
||||
// 条目只存在于内存、随进程结束消失,并在宽限期结束后回收,不落库。
|
||||
type rotatedRefreshToken struct {
|
||||
userID string
|
||||
pair *TokenPair
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// refreshFlight 是一次进行中的刷新,供并发请求共享结果。
|
||||
type refreshFlight struct {
|
||||
done chan struct{}
|
||||
pair *TokenPair
|
||||
err error
|
||||
}
|
||||
|
||||
// currentTime 返回当前时间;测试可注入固定时间源。
|
||||
func (s *TokenService) currentTime() time.Time {
|
||||
if s == nil || s.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// startRefreshFlight 尝试成为某个 refresh token 的首个刷新者。
|
||||
// 返回 (flight, true) 表示调用方负责执行刷新并调用 finishRefreshFlight;
|
||||
// 返回 (flight, false) 表示已有刷新在途,需要等待它的结果。
|
||||
func (s *TokenService) startRefreshFlight(tokenHash string) (*refreshFlight, bool) {
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
if s.refreshFlights == nil {
|
||||
s.refreshFlights = make(map[string]*refreshFlight)
|
||||
}
|
||||
if existing, ok := s.refreshFlights[tokenHash]; ok {
|
||||
return existing, false
|
||||
}
|
||||
flight := &refreshFlight{done: make(chan struct{})}
|
||||
s.refreshFlights[tokenHash] = flight
|
||||
return flight, true
|
||||
}
|
||||
|
||||
// finishRefreshFlight 发布刷新结果并唤醒等待者。
|
||||
func (s *TokenService) finishRefreshFlight(tokenHash string, pair *TokenPair, err error) {
|
||||
s.rotateMu.Lock()
|
||||
flight, ok := s.refreshFlights[tokenHash]
|
||||
if ok {
|
||||
delete(s.refreshFlights, tokenHash)
|
||||
flight.pair = pair
|
||||
flight.err = err
|
||||
}
|
||||
s.rotateMu.Unlock()
|
||||
if ok {
|
||||
// 关闭前已写入结果,等待方读取到的是完整的 happens-before 结果。
|
||||
close(flight.done)
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRefreshFlight 等待并发刷新完成并返回它的结果。
|
||||
func (s *TokenService) waitForRefreshFlight(flight *refreshFlight) (*TokenPair, error) {
|
||||
if flight == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
<-flight.done
|
||||
if flight.err != nil {
|
||||
return nil, flight.err
|
||||
}
|
||||
if flight.pair == nil {
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
return flight.pair, nil
|
||||
}
|
||||
|
||||
// rememberRotation 记录一次轮换的结果,并回收已过期的条目。
|
||||
func (s *TokenService) rememberRotation(tokenHash, userID string, pair *TokenPair) {
|
||||
if s == nil || pair == nil || strings.TrimSpace(tokenHash) == "" {
|
||||
return
|
||||
}
|
||||
now := s.currentTime()
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
if s.rotations == nil {
|
||||
s.rotations = make(map[string]rotatedRefreshToken)
|
||||
}
|
||||
for hash, entry := range s.rotations {
|
||||
if !now.Before(entry.expiresAt) {
|
||||
delete(s.rotations, hash)
|
||||
}
|
||||
}
|
||||
s.rotations[tokenHash] = rotatedRefreshToken{
|
||||
userID: userID,
|
||||
pair: pair,
|
||||
expiresAt: now.Add(refreshTokenReuseGrace),
|
||||
}
|
||||
}
|
||||
|
||||
// reusedRotation 在宽限期内返回同一次轮换的令牌对。
|
||||
func (s *TokenService) reusedRotation(tokenHash, userID string) (*TokenPair, bool) {
|
||||
if s == nil {
|
||||
return nil, false
|
||||
}
|
||||
now := s.currentTime()
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
entry, ok := s.rotations[tokenHash]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if !now.Before(entry.expiresAt) {
|
||||
delete(s.rotations, tokenHash)
|
||||
return nil, false
|
||||
}
|
||||
if userID != "" && entry.userID != "" && entry.userID != userID {
|
||||
return nil, false
|
||||
}
|
||||
return entry.pair, true
|
||||
}
|
||||
|
||||
// forgetRotationsForUser 丢弃某个用户的复用条目。
|
||||
// 显式登出/被踢下线必须立即生效,不能靠宽限期内的旧令牌继续换新。
|
||||
func (s *TokenService) forgetRotationsForUser(userID string) {
|
||||
if s == nil || strings.TrimSpace(userID) == "" {
|
||||
return
|
||||
}
|
||||
s.rotateMu.Lock()
|
||||
defer s.rotateMu.Unlock()
|
||||
for hash, entry := range s.rotations {
|
||||
if entry.userID == userID {
|
||||
delete(s.rotations, hash)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newRotationTestService(t *testing.T) (*TokenService, *repository.Container, *gorm.DB) {
|
||||
t.Helper()
|
||||
db := newServiceTestDB(t, &model.User{}, &model.RefreshToken{}, &model.Setting{})
|
||||
repos := repository.New(db)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
return NewTokenService(cfg, zap.NewNop(), repos), repos, db
|
||||
}
|
||||
|
||||
func seedRotationUser(t *testing.T, repos *repository.Container) *model.User {
|
||||
t.Helper()
|
||||
u := &model.User{Username: "race", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// TestConcurrentRefreshSharesSingleRotation 复现真实客户端的并发刷新:
|
||||
// WebSocket 重连与 401 拦截器、多个标签页会同时用同一个 refresh token 刷新。
|
||||
// 之前第二个请求必然拿到 401 revoked,前端据此清空会话(部署后被迫重新登录)。
|
||||
// 现在并发请求共享同一次轮换,全部成功且拿到同一对令牌。
|
||||
func TestConcurrentRefreshSharesSingleRotation(t *testing.T) {
|
||||
svc, repos, db := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const concurrency = 8
|
||||
pairs := make([]*TokenPair, concurrency)
|
||||
errs := make([]error, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
start := make(chan struct{})
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
pairs[idx], errs[idx] = svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
if errs[i] != nil {
|
||||
t.Fatalf("concurrent refresh %d failed: %v", i, errs[i])
|
||||
}
|
||||
if pairs[i].AccessToken != pairs[0].AccessToken || pairs[i].RefreshToken != pairs[0].RefreshToken {
|
||||
t.Fatalf("concurrent refresh %d returned a different pair", i)
|
||||
}
|
||||
}
|
||||
|
||||
// 只应该产生一个新的活跃 refresh token,而不是每个请求各轮换一次。
|
||||
var active int64
|
||||
if err := db.Model(&model.RefreshToken{}).
|
||||
Where("user_id = ? AND revoked = ?", u.ID, false).
|
||||
Count(&active).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active != 1 {
|
||||
t.Fatalf("active refresh tokens = %d, want 1", active)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshReuseAfterRotationReturnsSamePair 验证轮换完成后(并发窗口已
|
||||
// 结束)重复提交同一个旧令牌仍然是幂等的,客户端重试不会掉登录。
|
||||
func TestRefreshReuseAfterRotationReturnsSamePair(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reused, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse within grace window: %v", err)
|
||||
}
|
||||
if reused.RefreshToken != first.RefreshToken || reused.AccessToken != first.AccessToken {
|
||||
t.Fatal("reuse must return the pair issued by the first rotation")
|
||||
}
|
||||
// 复用的是新令牌,仍然可以继续轮换。
|
||||
if _, err := svc.Refresh(t.Context(), reused.RefreshToken); err != nil {
|
||||
t.Fatalf("refreshed token must stay usable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRotationReuseIsPerToken 验证复用表不会跨令牌串号:
|
||||
// 另一个令牌(例如被设备上限淘汰的那个)不会被误判为可复用。
|
||||
func TestRefreshRotationReuseIsPerToken(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
rotated, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 主动撤销另一个令牌(模拟登出/被踢下线),它不应享有复用宽限。
|
||||
if err := repos.RefreshToken.Revoke(t.Context(), repository.HashToken(other.RefreshToken)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), other.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("revoked token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshGraceWindowExpires 验证宽限期是有限的:超过之后旧令牌
|
||||
// 依旧被拒绝,管理员重置密码/踢下线等撤销语义不受影响。
|
||||
func TestRefreshGraceWindowExpires(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
svc.now = func() time.Time { return base.Add(refreshTokenReuseGrace + time.Second) }
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("error after grace window = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevokeAllDropsRotationReuse 验证登出/被踢下线立即生效:
|
||||
// 复用宽限期不能成为旧令牌继续换新的后门。
|
||||
func TestRevokeAllDropsRotationReuse(t *testing.T) {
|
||||
svc, repos, _ := newRotationTestService(t)
|
||||
u := seedRotationUser(t, repos)
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rotated, err := svc.Refresh(t.Context(), pair.RefreshToken)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.RevokeAll(t.Context(), u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("old token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), rotated.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("rotated token error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
@@ -48,11 +48,31 @@ type TokenService struct {
|
||||
// 因为 refresh token 从未落库而被判定无效,被强制踢回登录页,
|
||||
// 表现就是「经常登录报错」。
|
||||
delayedStores map[string]pendingRefreshToken
|
||||
|
||||
// now 是可替换的时间源(测试用);为 nil 时退回 time.Now。
|
||||
now func() time.Time
|
||||
|
||||
// rotateMu 保护 refreshFlights / rotations。
|
||||
rotateMu sync.Mutex
|
||||
// refreshFlights 记录「正在进行的刷新」:同一个 refresh token 被并发
|
||||
// 提交时,只有第一个请求去轮换,其余等待并共享同一个结果。
|
||||
refreshFlights map[string]*refreshFlight
|
||||
// rotations 记录刚轮换过的 refresh token 及其新令牌对,供宽限期内
|
||||
// 幂等复用(见 token_refresh_rotation.go)。
|
||||
rotations map[string]rotatedRefreshToken
|
||||
}
|
||||
|
||||
// NewTokenService 创建令牌服务实例。
|
||||
func NewTokenService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *TokenService {
|
||||
return &TokenService{cfg: cfg, log: log, repo: repo, delayedStores: make(map[string]pendingRefreshToken)}
|
||||
return &TokenService{
|
||||
cfg: cfg,
|
||||
log: log,
|
||||
repo: repo,
|
||||
delayedStores: make(map[string]pendingRefreshToken),
|
||||
now: time.Now,
|
||||
refreshFlights: make(map[string]*refreshFlight),
|
||||
rotations: make(map[string]rotatedRefreshToken),
|
||||
}
|
||||
}
|
||||
|
||||
// TokenPair 包含访问令牌和刷新令牌。
|
||||
@@ -168,9 +188,32 @@ func (s *TokenService) generateRefreshToken() (string, error) {
|
||||
}
|
||||
|
||||
// Refresh 使用 Refresh Token 轮换获取新的令牌对。
|
||||
//
|
||||
// refresh token 是一次性凭证,但真实客户端会并发使用同一个令牌(同一标签
|
||||
// 页的 WebSocket 重连与 401 拦截器、多个标签页、容器重启后同时刷新的多个
|
||||
// 页面)。因此这里做两件事:
|
||||
// 1. 同一个 token 的并发刷新共享同一次轮换(single-flight);
|
||||
// 2. 轮换后在宽限期内重复提交同一个 token,返回同一次轮换的令牌对。
|
||||
//
|
||||
// 二者共同保证「重复刷新不会把已经成功的会话打成 401 revoked」。
|
||||
func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*TokenPair, error) {
|
||||
tokenHash := repository.HashToken(refreshToken)
|
||||
|
||||
flight, leader := s.startRefreshFlight(tokenHash)
|
||||
if !leader {
|
||||
// 已有一次刷新在途:等它的结果,不再拿同一个一次性凭证轮换第二次。
|
||||
return s.waitForRefreshFlight(flight)
|
||||
}
|
||||
|
||||
pair, err := s.refreshOnce(ctx, tokenHash)
|
||||
s.finishRefreshFlight(tokenHash, pair, err)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
func (s *TokenService) refreshOnce(ctx context.Context, tokenHash string) (*TokenPair, error) {
|
||||
// 查找 Refresh Token 记录
|
||||
rt, err := s.repo.RefreshToken.FindByHash(ctx, tokenHash)
|
||||
if err != nil {
|
||||
@@ -180,7 +223,12 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
// 登录高峰/扫描写压力下,refresh token 可能还在后台补写队列里
|
||||
// 没来得及落库。此时令牌对客户端而言是合法的,不能判无效。
|
||||
pending, ok := s.pendingDelayedStore(tokenHash)
|
||||
if !ok || time.Now().After(pending.ExpiresAt) {
|
||||
if !ok || !s.currentTime().Before(pending.ExpiresAt) {
|
||||
// 也可能是「刚轮换完但从未落库」的令牌(同上,行本身不存在),
|
||||
// 宽限期内同样幂等复用。
|
||||
if pair, reused := s.reusedRotation(tokenHash, ""); reused {
|
||||
return pair, nil
|
||||
}
|
||||
return nil, ErrInvalidRefreshToken
|
||||
}
|
||||
rt = &model.RefreshToken{
|
||||
@@ -192,6 +240,11 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
|
||||
// 检查是否已撤销
|
||||
if rt.Revoked {
|
||||
// 刚被轮换过的 token 在宽限期内允许幂等复用,避免并发/重试的
|
||||
// 客户端拿到 revoked 401 后清空整个会话。
|
||||
if pair, ok := s.reusedRotation(tokenHash, rt.UserID); ok {
|
||||
return pair, nil
|
||||
}
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
|
||||
@@ -211,7 +264,7 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
if !user.IsActive {
|
||||
return nil, ErrUserInactive
|
||||
}
|
||||
if user.ExpiredAt != nil && time.Now().After(*user.ExpiredAt) {
|
||||
if user.ExpiredAt != nil && s.currentTime().After(*user.ExpiredAt) {
|
||||
return nil, ErrUserExpired
|
||||
}
|
||||
|
||||
@@ -222,11 +275,21 @@ func (s *TokenService) Refresh(ctx context.Context, refreshToken string) (*Token
|
||||
s.untrackDelayedStore(rt.UserID, tokenHash)
|
||||
|
||||
// 签发新的令牌对
|
||||
return s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
pair, err := s.IssuePairBestEffort(ctx, user.ID, user.Role, user.Tier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 必须在结束 flight 之前记住本次轮换:否则等待中的请求会在 flight 与
|
||||
// 复用表之间的空档里查不到记录,把并发刷新误判为 revoked。
|
||||
s.rememberRotation(tokenHash, user.ID, pair)
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// RevokeAll 撤销用户的所有 Refresh Token(用于登出)。
|
||||
func (s *TokenService) RevokeAll(ctx context.Context, userID string) error {
|
||||
// 复用条目必须一起丢弃:否则登出/被踢下线后的宽限期内,
|
||||
// 旧令牌仍能换回一对有效令牌。
|
||||
s.forgetRotationsForUser(userID)
|
||||
return s.repo.RefreshToken.RevokeByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,12 +45,46 @@ func TestRefreshAcceptsPendingDelayedToken(t *testing.T) {
|
||||
if pair == nil || pair.AccessToken == "" || pair.RefreshToken == "" {
|
||||
t.Fatalf("Refresh returned incomplete pair: %+v", pair)
|
||||
}
|
||||
// 轮换后旧令牌应从 pending 表移除,不能再次使用。
|
||||
// 轮换后旧令牌应从 pending 表移除。
|
||||
if _, still := svc.pendingDelayedStore(hash); still {
|
||||
t.Fatal("rotated pending token still tracked")
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), refreshToken); err == nil {
|
||||
t.Fatal("rotated pending token should not refresh twice")
|
||||
// 宽限期内重复提交同一个旧令牌返回同一次轮换的结果(幂等),
|
||||
// 而不是把并发/重试的客户端打成 401 并清空会话。
|
||||
reused, err := svc.Refresh(t.Context(), refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("reuse within grace window should be idempotent: %v", err)
|
||||
}
|
||||
if reused.AccessToken != pair.AccessToken || reused.RefreshToken != pair.RefreshToken {
|
||||
t.Fatal("reuse should return the same rotated pair")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshRejectsRotatedTokenAfterGraceWindow 验证宽限期结束后,
|
||||
// 旧令牌仍按已撤销处理(token 复用检测语义不变)。
|
||||
func TestRefreshRejectsRotatedTokenAfterGraceWindow(t *testing.T) {
|
||||
repos := newTokenTestRepo(t)
|
||||
cfg := &config.Config{}
|
||||
cfg.Secrets.JWTSecret = "test-secret"
|
||||
svc := NewTokenService(cfg, zap.NewNop(), repos)
|
||||
|
||||
u := &model.User{Username: "u1", PasswordHash: "x", Role: "user", Tier: "free", IsActive: true}
|
||||
if err := repos.User.Create(t.Context(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pair, err := svc.IssuePair(t.Context(), u.ID, u.Role, u.Tier)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); err != nil {
|
||||
t.Fatalf("first refresh: %v", err)
|
||||
}
|
||||
|
||||
base := time.Now()
|
||||
svc.now = func() time.Time { return base.Add(2 * refreshTokenReuseGrace) }
|
||||
if _, err := svc.Refresh(t.Context(), pair.RefreshToken); !errors.Is(err, ErrTokenRevoked) {
|
||||
t.Fatalf("expired reuse error = %v, want ErrTokenRevoked", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { appRoutes, type AppRoute } from './appRoutes'
|
||||
import { Layout } from './components/Layout'
|
||||
import { RequireAdmin, RequireAuth } from './components/RequireAuth'
|
||||
import { ReaderMiniPlayer } from './pages/reader/ReaderMiniPlayer'
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage').then((m) => ({ default: m.LoginPage })))
|
||||
const ReaderRoutes = lazy(() => import('./pages/reader/ReaderRoutes').then((m) => ({ default: m.default })))
|
||||
|
||||
@@ -93,6 +94,8 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* 听书迷你条:播放引擎常驻,离开阅读页后仍可从这里控制 */}
|
||||
<ReaderMiniPlayer />
|
||||
</Suspense>
|
||||
</AppErrorBoundary>
|
||||
)
|
||||
|
||||
+23
-23
@@ -2,9 +2,12 @@ import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { getActivePlayProfileId, getActivePlayProfilePinToken } from '../stores/playProfile'
|
||||
import type { RefreshOutcome } from '../utils/authRefresh'
|
||||
|
||||
// Single axios instance used by every API helper. Adds the JWT to outgoing
|
||||
// requests and routes 401s back to the login page.
|
||||
// requests and refreshes it on 401. Only an explicit server-side rejection of
|
||||
// the refresh token sends the user back to the login page; transient failures
|
||||
// (deploy/restart window, gateway errors, timeouts) keep the stored session.
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
@@ -92,33 +95,30 @@ api.interceptors.response.use(
|
||||
originalRequest._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const refreshed = await useAuthStore.getState().tokenRefresh()
|
||||
if (refreshed) {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
onTokenRefreshed(newToken || '')
|
||||
isRefreshing = false
|
||||
return api(originalRequest)
|
||||
// 刷新可能因为部署/重启窗口的瞬时故障失败。只有服务端明确判定
|
||||
// 凭证失效(401/403)时才清空会话并跳登录页;网络错误、网关 502、
|
||||
// 超时只是暂时不可用,保留本地令牌等下一次重试。
|
||||
const outcome: RefreshOutcome = await useAuthStore
|
||||
.getState()
|
||||
.refreshSession()
|
||||
.catch((): RefreshOutcome => 'transient')
|
||||
isRefreshing = false
|
||||
|
||||
if (outcome === 'refreshed') {
|
||||
const newToken = useAuthStore.getState().token
|
||||
if (newToken && originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
}
|
||||
} catch (refreshError) {
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(refreshError)
|
||||
onTokenRefreshed(newToken || '')
|
||||
return api(originalRequest)
|
||||
}
|
||||
|
||||
onTokenRefreshFailed(err)
|
||||
if (outcome === 'invalid') {
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
|
||||
// Refresh failed, logout
|
||||
isRefreshing = false
|
||||
onTokenRefreshFailed(err)
|
||||
useAuthStore.getState().logout()
|
||||
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
+83
-8
@@ -1,4 +1,7 @@
|
||||
import { LONG_REQUEST_TIMEOUT, api } from './client'
|
||||
// 只引类型:阅读器偏好的字段定义在 store(它是本地状态的形状),
|
||||
// 这里作为 /reader/profile 的线上载荷复用,类型导入不会产生运行时依赖。
|
||||
import type { ReaderSettingsProfile } from '../stores/readerSettings'
|
||||
|
||||
// 阅读子系统 API(/api/reader/*),字段与后端 model/reader.go 对齐。
|
||||
|
||||
@@ -59,6 +62,8 @@ export interface ReaderBook {
|
||||
intro: string
|
||||
type: number
|
||||
latest_chapter_title: string
|
||||
/** 最后一次检测到「目录末尾章节变化」的时间(毫秒,0 表示未检测到更新)。 */
|
||||
latest_chapter_time: number
|
||||
total_chapter_num: number
|
||||
dur_chapter_index: number
|
||||
dur_chapter_pos: number
|
||||
@@ -74,6 +79,12 @@ export interface ReaderBook {
|
||||
close_credits: number
|
||||
}
|
||||
|
||||
/** 书架分组(对齐影视模块的媒体库标签):组名 + 组内书籍 ID,顺序即组内展示顺序。 */
|
||||
export interface BookGroup {
|
||||
name: string
|
||||
book_ids: string[]
|
||||
}
|
||||
|
||||
export interface ReaderBookInfo {
|
||||
name: string
|
||||
author: string
|
||||
@@ -107,10 +118,26 @@ export interface ReaderChapterContent {
|
||||
tracks?: string[]
|
||||
images?: string[]
|
||||
image_style?: string
|
||||
/** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
|
||||
comments?: ReaderContentComment[]
|
||||
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
|
||||
transcoding?: boolean
|
||||
}
|
||||
|
||||
/** 正文里的一条段评(服务端从 <comment> / 内嵌评论图里解析出来)。 */
|
||||
export interface ReaderContentComment {
|
||||
/** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
|
||||
line: number
|
||||
/** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
|
||||
count: number
|
||||
/** 评论地址(书源 showCmt 的第一个参数)。 */
|
||||
url: string
|
||||
/** 段评 / 本章说。 */
|
||||
label?: string
|
||||
/** 独占一行的整块评论(章末「本章说」)。 */
|
||||
block?: boolean
|
||||
}
|
||||
|
||||
export interface ReaderReplaceRule {
|
||||
id: string
|
||||
name: string
|
||||
@@ -163,6 +190,8 @@ export interface ReaderBrowserPage {
|
||||
title: string
|
||||
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
|
||||
mode: 'wait' | 'open'
|
||||
/** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
|
||||
seq: number
|
||||
/** iframe 承载地址(同源,带签名)。 */
|
||||
page_url: string
|
||||
refetch: boolean
|
||||
@@ -212,6 +241,9 @@ export const readerAPI = {
|
||||
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
|
||||
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
|
||||
api.post('/reader/browser/result', body).then((r) => r.data),
|
||||
// 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
|
||||
openContentComment: (body: { book_id: string; url: string; title?: string }) =>
|
||||
api.post<ReaderBrowserPage>('/reader/comments/open', body).then((r) => r.data),
|
||||
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
|
||||
browserXHR: (body: {
|
||||
id: string
|
||||
@@ -234,11 +266,14 @@ export const readerAPI = {
|
||||
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
|
||||
|
||||
// ── 搜索 / 详情 / 目录 / 正文 ──
|
||||
search: (key: string) =>
|
||||
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
|
||||
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
|
||||
// page 从 1 开始:书源 searchUrl 里的 {{page}},前端滚到底逐页取再增量合并。
|
||||
search: (key: string, sourceIds: string[] = [], page = 1) =>
|
||||
api
|
||||
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
|
||||
'/reader/search',
|
||||
{ key },
|
||||
{ key, source_ids: sourceIds, page },
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
@@ -246,15 +281,17 @@ export const readerAPI = {
|
||||
api.get<ReaderBookInfo>('/reader/book-info', { params, timeout: LONG_REQUEST_TIMEOUT }).then((r) => r.data),
|
||||
toc: (params: { source_id?: string; source_url?: string; book_url: string; toc_url: string }) =>
|
||||
api
|
||||
.get<{ chapters: ReaderTocChapter[] }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data.chapters),
|
||||
.get<{ chapters: ReaderTocChapter[] | null }>('/reader/toc', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
// 后端把 nil 目录编码成 null;调用方一律按数组处理,
|
||||
// 否则 `chapters.length` 会抛 TypeError(页面只剩一句 JS 报错)。
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
content: (params: { source_id?: string; source_url?: string; book_url: string; chapter_url: string }) =>
|
||||
api
|
||||
.get<ReaderChapterContent>('/reader/content', { params, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 书架 ──
|
||||
listBooks: () => api.get<{ books: ReaderBook[] }>('/reader/books').then((r) => r.data.books),
|
||||
listBooks: () => api.get<{ books: ReaderBook[] | null }>('/reader/books').then((r) => r.data.books ?? []),
|
||||
addBook: (body: { origin: ReaderSearchOrigin; name: string; author: string; cover_url: string }) =>
|
||||
api.post<ReaderBook>('/reader/books', body).then((r) => r.data),
|
||||
/** 上传本地书籍(TXT / EPUB),服务端解析目录后加入书架。 */
|
||||
@@ -283,6 +320,36 @@ export const readerAPI = {
|
||||
.post<ReaderBook>('/reader/local/audiobooks', { path }, { timeout: LONG_REQUEST_TIMEOUT })
|
||||
.then((r) => r.data),
|
||||
removeBook: (id: string) => api.delete(`/reader/books/${id}`),
|
||||
/**
|
||||
* 读取当前账号的阅读器偏好(主题 / 排版 / 听书 / 书架展示)。
|
||||
* 从未保存过时返回 null,由前端用本地的值播种。
|
||||
*/
|
||||
getReaderSettings: () =>
|
||||
api.get<{ profile: ReaderSettingsProfile | null }>('/reader/profile').then((r) => r.data.profile),
|
||||
/** 覆盖保存阅读器偏好(服务端会做范围收敛)。 */
|
||||
saveReaderSettings: (body: ReaderSettingsProfile) =>
|
||||
api.put<{ profile: ReaderSettingsProfile }>('/reader/profile', body).then((r) => r.data.profile),
|
||||
|
||||
// ── 书架分组(每个用户一份,仿影视模块的媒体库标签)──
|
||||
/** 读取当前账号的书架分组;没有分组时返回空数组。 */
|
||||
getBookGroups: () =>
|
||||
api.get<{ groups: BookGroup[] | null }>('/reader/book-groups').then((r) => r.data.groups ?? []),
|
||||
/** 覆盖保存书架分组:服务端会收敛成「一书一组」并丢弃已不在书架上的书。 */
|
||||
setBookGroups: (groups: BookGroup[]) =>
|
||||
api.put<{ groups: BookGroup[] | null }>('/reader/book-groups', { groups }).then((r) => r.data.groups ?? []),
|
||||
/**
|
||||
* 更新目录(对应 legado 书架的「更新目录」):重抓书架里全部网络书籍的目录,
|
||||
* 覆盖章节缓存并刷新「最近更新」时间。返回本次刷新的汇总:
|
||||
* updated 有新章节 / unchanged 抓成功但没新章节 / failed 抓取或写入失败。
|
||||
*/
|
||||
refreshBooksToc: () =>
|
||||
api
|
||||
.post<{ total: number; updated: number; unchanged: number; failed: number }>(
|
||||
'/reader/shelf/refresh-toc',
|
||||
{},
|
||||
longOpts,
|
||||
)
|
||||
.then((r) => r.data),
|
||||
/**
|
||||
* 换源:把书架里的书切到另一个书源。
|
||||
* 阅读进度保留,旧源目录缓存由服务端清空并按新源重新预热。
|
||||
@@ -295,12 +362,20 @@ export const readerAPI = {
|
||||
saveAudioConfig: (id: string, body: { open_credits: number; close_credits: number }) =>
|
||||
api.put(`/reader/books/${id}/audio-config`, body),
|
||||
listChapters: (id: string) =>
|
||||
api.get<{ chapters: ReaderChapter[] }>(`/reader/books/${id}/chapters`).then((r) => r.data.chapters),
|
||||
api
|
||||
.get<{ chapters: ReaderChapter[] | null }>(`/reader/books/${id}/chapters`)
|
||||
.then((r) => r.data.chapters ?? []),
|
||||
saveChapters: (id: string, chapters: ReaderChapter[]) => api.post(`/reader/books/${id}/chapters`, { chapters }),
|
||||
// 书架维度正文(服务端已应用书源 replaceRegex 与用户替换净化规则)
|
||||
bookContent: (id: string, chapter: number) =>
|
||||
// signal:换章/换源/离开页面时取消在途请求——慢源上一章要等十几秒,不取消的话
|
||||
// 旧源的响应会白等,换源后还可能把旧源的正文写进新书的缓存。
|
||||
bookContent: (id: string, chapter: number, signal?: AbortSignal) =>
|
||||
api
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, { params: { chapter }, timeout: LONG_REQUEST_TIMEOUT })
|
||||
.get<ReaderChapterContent>(`/reader/books/${id}/content`, {
|
||||
params: { chapter },
|
||||
timeout: LONG_REQUEST_TIMEOUT,
|
||||
signal,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
// ── 替换净化规则 ──
|
||||
|
||||
+50
-31
@@ -1,37 +1,56 @@
|
||||
// 令牌刷新 API 模块
|
||||
import { api } from './client'
|
||||
// 令牌刷新 API 模块。
|
||||
import { RefreshRequestError, type RefreshTokens } from '../utils/authRefresh'
|
||||
|
||||
// 刷新令牌请求/响应
|
||||
export interface RefreshTokenRequest {
|
||||
refresh_token: string
|
||||
}
|
||||
const REFRESH_ENDPOINT = '/api/auth/refresh'
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
// 刷新是短请求:网关/服务重启时尽快失败并让调用方重试,
|
||||
// 不要用默认的长超时把页面卡在等待里。
|
||||
const REFRESH_TIMEOUT_MS = 15_000
|
||||
|
||||
// 刷新访问令牌。
|
||||
//
|
||||
// 后端响应封装在 { code, message, data } 里,需解包 .data。
|
||||
// /auth/login 的响应是直接展开的({tokens:..., user:...}),
|
||||
// /auth/refresh 的响应是包装过的 — 这里负责拉平成前端使用的 shape。
|
||||
export async function refreshToken(refreshToken: string): Promise<RefreshTokenResponse> {
|
||||
const resp = await api.post<{
|
||||
code: number
|
||||
message: string
|
||||
data: RefreshTokenResponse
|
||||
}>('/auth/refresh', { refresh_token: refreshToken })
|
||||
const body = resp.data
|
||||
if (!body || !body.data || !body.data.token) {
|
||||
throw new Error(body?.message || 'refresh failed')
|
||||
/**
|
||||
* 用 refresh token 换取新的令牌对。
|
||||
*
|
||||
* 刻意使用 fetch 而不是共享的 axios 实例:
|
||||
* - 刷新请求的失败不能进入 401 拦截器,否则会递归触发刷新/登出;
|
||||
* - 调用方需要拿到 HTTP 状态码,以区分「凭证失效」和「服务暂时不可用」。
|
||||
*
|
||||
* 服务端响应形如 { code, message, data: { token, refresh_token, ... } }。
|
||||
*/
|
||||
export async function requestRefreshTokens(refreshToken: string): Promise<RefreshTokens> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), REFRESH_TIMEOUT_MS)
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(REFRESH_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch {
|
||||
// 网络不可达、被中止或超时:没有 HTTP 状态码,按「暂时不可用」处理。
|
||||
throw new RefreshRequestError('refresh request failed')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
// 登出
|
||||
export async function logout(): Promise<void> {
|
||||
await api.post('/me/logout')
|
||||
const body = (await resp.json().catch(() => null)) as
|
||||
| { code?: number; message?: string; data?: Partial<RefreshTokens> }
|
||||
| null
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new RefreshRequestError(body?.message ?? 'refresh failed', resp.status)
|
||||
}
|
||||
|
||||
const token = body?.data?.token
|
||||
const nextRefreshToken = body?.data?.refresh_token
|
||||
if (!token || !nextRefreshToken) {
|
||||
// 200 但没有可用令牌(例如被网关/代理改写了响应):同样按暂时不可用处理,
|
||||
// 不要据此清空用户会话。
|
||||
throw new RefreshRequestError('malformed refresh response')
|
||||
}
|
||||
return { token, refresh_token: nextRefreshToken }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FolderInput, Info, MoreVertical, Trash2 } from 'lucide-react'
|
||||
|
||||
import type { ReaderBook } from '../api/reader'
|
||||
import { bookDetailPath } from '../pages/reader/bookshelfModel'
|
||||
|
||||
/**
|
||||
* 书籍卡片上的「⋯」操作菜单:把「移到分组 / 书籍详情 / 移出书架」收进一个下拉里,
|
||||
* 不再让三个图标直接铺在封面上。
|
||||
*
|
||||
* 定位上有三点刻意的处理:
|
||||
* 1. 菜单用 `position: fixed` + 实测位置渲染(而不是绝对定位挂在卡片里)。
|
||||
* 网格卡片为了裁圆角带 `overflow-hidden`,绝对定位的子元素会被裁掉;固定定位能
|
||||
* 逃出这个裁剪,也不用引入 portal。
|
||||
* 2. 网格布局下菜单与书籍卡片**同宽、左边缘对齐**、向下展开,看起来像卡片自己展开的
|
||||
* 动作面板;宽度取自卡片(`data-book-card` 标记的元素),列数变化时自动跟随。
|
||||
* 3. 下方确实放不下时才向上翻,并夹在视口内,避免贴底部的卡片点开看不见。
|
||||
*/
|
||||
export function BookCardMenu({
|
||||
book,
|
||||
onPickGroup,
|
||||
onRemove,
|
||||
variant = 'overlay',
|
||||
}: {
|
||||
book: ReaderBook
|
||||
onPickGroup: () => void
|
||||
onRemove: () => void
|
||||
/** overlay:网格封面上的圆形按钮;plain:列表布局右侧的浅色按钮。 */
|
||||
variant?: 'overlay' | 'plain'
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
// 进场动画用:先以「缩起来」渲染一帧,再切到展开态触发过渡
|
||||
const [shown, setShown] = useState(false)
|
||||
const [pos, setPos] = useState<{ top: number; left: number; width: number; up: boolean } | null>(null)
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
/** 列表布局(一行很宽)不适合跟行同宽,保持固定宽度。 */
|
||||
const PLAIN_WIDTH = 176
|
||||
/** 网格卡片过窄时给个下限,保证文字不挤成两行。 */
|
||||
const MIN_WIDTH = 132
|
||||
/** 三个菜单项 + 分隔线的大致高度,用来判断下方是否放得下。 */
|
||||
const MENU_HEIGHT = 140
|
||||
const GAP = 8
|
||||
const EDGE = 8
|
||||
|
||||
const openMenu = () => {
|
||||
const el = btnRef.current
|
||||
if (!el) return
|
||||
const btnRect = el.getBoundingClientRect()
|
||||
|
||||
// 网格:取卡片本身的位置与宽度(由 BookshelfCard 打上 data-book-card 标记)
|
||||
const cardEl = variant === 'overlay' ? el.closest('[data-book-card]') : null
|
||||
const cardRect = cardEl instanceof HTMLElement ? cardEl.getBoundingClientRect() : null
|
||||
|
||||
const width = cardRect
|
||||
? Math.max(MIN_WIDTH, Math.min(cardRect.width, window.innerWidth - EDGE * 2))
|
||||
: PLAIN_WIDTH
|
||||
const maxLeft = Math.max(EDGE, window.innerWidth - width - EDGE)
|
||||
const left = cardRect
|
||||
? Math.min(Math.max(EDGE, cardRect.left), maxLeft)
|
||||
: Math.min(Math.max(EDGE, btnRect.right - width), maxLeft)
|
||||
|
||||
// 默认向下;只有下方真的放不下才向上翻
|
||||
const spaceBelow = window.innerHeight - btnRect.bottom
|
||||
const up = spaceBelow < MENU_HEIGHT + GAP + EDGE && btnRect.top >= MENU_HEIGHT + GAP + EDGE
|
||||
const top = up
|
||||
? Math.max(EDGE, btnRect.top - GAP - MENU_HEIGHT)
|
||||
: Math.min(btnRect.bottom + GAP, Math.max(EDGE, window.innerHeight - MENU_HEIGHT - EDGE))
|
||||
|
||||
setShown(false)
|
||||
setPos({ top, left, width, up })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = () => setOpen(false)
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node
|
||||
if (btnRef.current?.contains(target)) return
|
||||
// 菜单本身在 fixed 层里,点它不算「点外部」
|
||||
if (target instanceof Element && target.closest('[data-book-card-menu]')) return
|
||||
close()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
document.addEventListener('pointerdown', onPointerDown, true)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
// 滚动/缩放后按钮位置会变,菜单会脱位,直接关掉更省事
|
||||
window.addEventListener('scroll', close, true)
|
||||
window.addEventListener('resize', close)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('scroll', close, true)
|
||||
window.removeEventListener('resize', close)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// 挂载后下一帧再切到展开态,否则过渡不会触发
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const id = window.requestAnimationFrame(() => setShown(true))
|
||||
return () => window.cancelAnimationFrame(id)
|
||||
}, [open])
|
||||
|
||||
const run = (action: () => void) => {
|
||||
setOpen(false)
|
||||
action()
|
||||
}
|
||||
|
||||
const triggerClass =
|
||||
variant === 'overlay'
|
||||
? 'rounded-full bg-black/55 p-1.5 text-white opacity-85 backdrop-blur transition hover:bg-brand-500 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white'
|
||||
: 'rounded-lg p-1.5 text-[var(--app-muted)] transition hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
|
||||
// 每行自己声明完整配色(含文字颜色),避免同类 utility 互相覆盖
|
||||
const itemBase =
|
||||
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2.5 text-left text-xs font-bold transition-colors'
|
||||
const itemNormal = 'text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]'
|
||||
const itemDanger = 'text-red-500/90 hover:bg-red-500/10 hover:text-red-500'
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={`更多操作:${book.name}`}
|
||||
title="更多操作"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (open) setOpen(false)
|
||||
else openMenu()
|
||||
}}
|
||||
className={triggerClass}
|
||||
>
|
||||
<MoreVertical size={variant === 'overlay' ? 13 : 16} />
|
||||
</button>
|
||||
|
||||
{open && pos && (
|
||||
<div
|
||||
data-book-card-menu=""
|
||||
role="menu"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
width: pos.width,
|
||||
transformOrigin: pos.up ? 'bottom right' : 'top right',
|
||||
}}
|
||||
className={`fixed z-[120] rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-1.5 shadow-2xl transition duration-100 ease-out ${
|
||||
shown ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button type="button" role="menuitem" className={`${itemBase} ${itemNormal}`} onClick={() => run(onPickGroup)}>
|
||||
<FolderInput size={14} className="shrink-0 text-brand-500" /> 移到分组
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${itemBase} ${itemNormal}`}
|
||||
onClick={() => run(() => navigate(bookDetailPath(book)))}
|
||||
>
|
||||
<Info size={14} className="shrink-0 opacity-70" /> 书籍详情
|
||||
</button>
|
||||
<div className="my-1 border-t border-[var(--app-border)]" />
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`${itemBase} ${itemDanger}`}
|
||||
onClick={() => run(onRemove)}
|
||||
>
|
||||
<Trash2 size={14} className="shrink-0" /> 移出书架
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Library, ListFilter } from 'lucide-react'
|
||||
|
||||
import type { BookGroupTab } from '../utils/readerBookGroups'
|
||||
|
||||
/**
|
||||
* 书架分组栏(对齐影视模块的 LibraryTagBar):
|
||||
* 固定「全部」+ 每个分组一个页签 +(有分组时)「未分组」殿后,选中后只展示该分组的书。
|
||||
*
|
||||
* 只是展示层过滤,不改动书架数据本身。这里只负责展示与选中:新建 / 重命名 / 删除 /
|
||||
* 拖拽排序都在「管理」菜单的「管理分组」弹窗里做,分组栏保持干净。
|
||||
*/
|
||||
export function BookGroupBar({
|
||||
tabs,
|
||||
selectedGroupId,
|
||||
onSelect,
|
||||
}: {
|
||||
tabs: BookGroupTab[]
|
||||
selectedGroupId: string
|
||||
onSelect: (groupId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5 border-b border-[var(--app-border)] pb-3">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === selectedGroupId
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
aria-pressed={active}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-bold transition-colors ${
|
||||
active
|
||||
? 'border-brand-500/60 bg-brand-500/10 text-brand-600'
|
||||
: 'border-[var(--app-border)] bg-[var(--app-panel-soft)] text-[var(--app-subtle)] hover:border-brand-500/40 hover:text-[var(--app-text)]'
|
||||
}`}
|
||||
title={
|
||||
tab.kind === 'all'
|
||||
? '显示全部书籍'
|
||||
: tab.kind === 'ungrouped'
|
||||
? '显示没有归入任何分组的书籍'
|
||||
: `只看「${tab.name}」分组`
|
||||
}
|
||||
>
|
||||
{tab.kind === 'all' && <Library size={13} />}
|
||||
{tab.kind === 'ungrouped' && <ListFilter size={13} />}
|
||||
<span className="max-w-[8rem] truncate">{tab.name}</span>
|
||||
<span className={`font-mono text-[10px] ${active ? 'text-brand-500' : 'text-[var(--app-muted)]'}`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Check, FolderInput, Layers, X } from 'lucide-react'
|
||||
|
||||
import type { BookGroup, ReaderBook } from '../api/reader'
|
||||
|
||||
/**
|
||||
* 「移到分组」选择框:给一本书指定所属分组。
|
||||
*
|
||||
* 书籍归组没有放在分组管理弹窗里 —— 书架可能有上千本,逐个列出来既慢又难找,
|
||||
* 直接在卡片上点「分组」更顺手。一本书只归一个分组,选「未分组」即移出所有分组。
|
||||
*/
|
||||
export function BookGroupPickerDialog({
|
||||
book,
|
||||
groups,
|
||||
currentGroupId,
|
||||
busy = false,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
book: ReaderBook
|
||||
groups: BookGroup[]
|
||||
/** 当前所属分组名;空串表示未分组。 */
|
||||
currentGroupId: string
|
||||
busy?: boolean
|
||||
onPick: (groupName: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="移到分组"
|
||||
className="flex max-h-[80vh] w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-sm font-bold text-[var(--app-text)]">移到分组</h3>
|
||||
<p className="mt-0.5 truncate text-2xs text-[var(--app-muted)]">《{book.name}》</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onPick('')}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<Layers size={14} className="shrink-0 text-[var(--app-muted)]" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">未分组</span>
|
||||
{currentGroupId === '' && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center text-2xs text-[var(--app-muted)]">
|
||||
还没有分组,先在书架分组栏的「管理分组」里新建一个
|
||||
</p>
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<button
|
||||
key={group.name}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onPick(group.name)}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2.5 text-left transition hover:bg-[var(--app-hover)] disabled:opacity-50"
|
||||
>
|
||||
<FolderInput size={14} className="shrink-0 text-brand-500" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{group.name}
|
||||
</span>
|
||||
{currentGroupId === group.name && <Check size={14} className="shrink-0 text-brand-600" />}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type LoadMoreSentinelProps = {
|
||||
/** 还有下一页;false 时整个哨兵不渲染(没有更多了)。 */
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 上一页加载失败的原因:停掉自动加载、改成手动重试,见下方 observer 注释。 */
|
||||
error?: string
|
||||
onRetry?: () => void
|
||||
label?: string
|
||||
loadingLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 触底加载哨兵:自身进入滚动容器(应用主滚动区 #app-main-scroll)附近时回调
|
||||
* onLoadMore,同时留一个可点的按钮兜底(观察器不可用、或用户想手动催一下)。
|
||||
*
|
||||
* 失败时必须停掉观察器:失败后 loadingMore 会回到 false,观察器一旦重建就会
|
||||
* 立刻再次命中(哨兵还在视口里),变成「失败即重试」的死循环;所以 error 非空
|
||||
* 时只渲染重试按钮,等用户点了、错误清掉再重新挂观察器。
|
||||
*/
|
||||
export function LoadMoreSentinel({
|
||||
hasMore,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
error = '',
|
||||
onRetry,
|
||||
label = '加载更多',
|
||||
loadingLabel = '加载中…',
|
||||
}: LoadMoreSentinelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element || !hasMore || error) return
|
||||
// 观察根是主滚动容器而不是视口:页面是嵌在 #app-main-scroll 里滚的。
|
||||
const root = document.getElementById('app-main-scroll')
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (!loadingMore && entries.some((entry) => entry.isIntersecting)) {
|
||||
onLoadMore()
|
||||
}
|
||||
},
|
||||
{ root, rootMargin: '600px 0px', threshold: 0 },
|
||||
)
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, onLoadMore, error])
|
||||
|
||||
if (!hasMore) return null
|
||||
|
||||
const showRetry = !!error && !!onRetry
|
||||
return (
|
||||
<div ref={ref} className="flex flex-col items-center gap-1.5 py-8">
|
||||
{error && <p className="text-2xs text-[var(--app-muted)]">{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingMore}
|
||||
onClick={showRetry ? onRetry : onLoadMore}
|
||||
className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel)] px-4 py-2 text-sm font-bold text-[var(--app-subtle)] transition-colors hover:bg-[var(--app-hover)] hover:text-[var(--app-text)] disabled:cursor-wait disabled:opacity-60"
|
||||
>
|
||||
{loadingMore ? loadingLabel : label}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, GripVertical, Loader2, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import { confirmAction } from './confirmAction'
|
||||
import { useDragReorder } from '../hooks/useDragReorder'
|
||||
import { MAX_BOOK_GROUPS, bookGroupNameError, normalizeBookGroupName } from '../utils/readerBookGroups'
|
||||
import type { BookGroup } from '../api/reader'
|
||||
|
||||
export type ManageBookGroupsDialogProps = {
|
||||
groups: BookGroup[]
|
||||
/** 分组名 → 当前书架里实际属于该组的书籍数(不统计已失效的 ID)。 */
|
||||
bookCounts: Record<string, number>
|
||||
saving: boolean
|
||||
onCreate: (name: string) => Promise<string>
|
||||
onRename: (from: string, to: string) => Promise<void>
|
||||
onRemove: (name: string) => Promise<void>
|
||||
/** 按给定分组名顺序重排(拖拽排序的落点)。 */
|
||||
onReorder: (orderedNames: string[]) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组管理对话框(对齐影视模块的管理标签弹窗,去掉成员分配那半边):
|
||||
* 新建 / 重命名 / 删除分组,拖拽调整分组栏顺序。
|
||||
*
|
||||
* 书籍归组不在这里做,而是在书架卡片上的「分组」按钮里逐个/批量指定 —— 书可能有上千本,
|
||||
* 逐个列在弹窗里既慢又难找。
|
||||
*/
|
||||
export function ManageBookGroupsDialogView({
|
||||
groups,
|
||||
bookCounts,
|
||||
saving,
|
||||
onCreate,
|
||||
onRename,
|
||||
onRemove,
|
||||
onReorder,
|
||||
onClose,
|
||||
}: ManageBookGroupsDialogProps) {
|
||||
const [draftName, setDraftName] = useState('')
|
||||
const [renaming, setRenaming] = useState('')
|
||||
const [renameDraft, setRenameDraft] = useState('')
|
||||
const { draggingId, dragOverId, dragProps } = useDragReorder(
|
||||
groups.map((group) => group.name),
|
||||
onReorder,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const submitCreate = () => {
|
||||
const name = normalizeBookGroupName(draftName)
|
||||
if (!name) return
|
||||
const err = bookGroupNameError(draftName, groups)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
setDraftName('')
|
||||
void onCreate(name)
|
||||
}
|
||||
|
||||
const submitRename = (from: string) => {
|
||||
const name = normalizeBookGroupName(renameDraft)
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
if (!name || name === from) return
|
||||
const err = bookGroupNameError(renameDraft, groups, from)
|
||||
if (err) {
|
||||
toast.error(err)
|
||||
return
|
||||
}
|
||||
void onRename(from, name)
|
||||
}
|
||||
|
||||
const handleRemove = async (name: string) => {
|
||||
const ok = await confirmAction({
|
||||
title: '删除分组',
|
||||
message: `删除「${name}」后,组内的书会回到「未分组」,书籍本身不会被移除。确定吗?`,
|
||||
confirmText: '删除',
|
||||
danger: true,
|
||||
})
|
||||
if (!ok) return
|
||||
void onRemove(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="管理分组"
|
||||
className="flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-[var(--app-text)]">管理分组</h3>
|
||||
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
|
||||
拖拽调整顺序 · 最多 {MAX_BOOK_GROUPS} 个分组
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-5 py-3">
|
||||
<input
|
||||
value={draftName}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitCreate()
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
placeholder="新分组名"
|
||||
className="h-9 min-w-0 flex-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 text-xs font-semibold text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)] focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitCreate}
|
||||
disabled={saving || !draftName.trim() || groups.length >= MAX_BOOK_GROUPS}
|
||||
className="inline-flex shrink-0 items-center gap-1 rounded-xl border border-[var(--app-border)] px-3 py-2 text-xs font-bold text-brand-600 transition hover:bg-[var(--app-hover)] disabled:opacity-40"
|
||||
>
|
||||
<Plus size={13} /> 新建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{groups.length === 0 ? (
|
||||
<p className="py-10 text-center text-xs text-[var(--app-muted)]">
|
||||
还没有分组,先在上方新建一个,再到书籍卡片上用「分组」按钮把书放进去
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{groups.map((group) => {
|
||||
const isRenaming = renaming === group.name
|
||||
const dragging = draggingId === group.name
|
||||
const over = dragOverId === group.name
|
||||
return (
|
||||
<div
|
||||
key={group.name}
|
||||
{...dragProps(group.name)}
|
||||
className={`flex items-center gap-2 rounded-xl border px-2 py-2 transition ${
|
||||
dragging
|
||||
? 'border-brand-500/60 opacity-60'
|
||||
: over
|
||||
? 'border-brand-500/60 bg-brand-500/5'
|
||||
: 'border-transparent hover:bg-[var(--app-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 cursor-grab p-1 text-[var(--app-muted)] active:cursor-grabbing"
|
||||
title="拖拽调整顺序"
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
|
||||
{isRenaming ? (
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
value={renameDraft}
|
||||
onChange={(event) => setRenameDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
submitRename(group.name)
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}
|
||||
}}
|
||||
maxLength={24}
|
||||
className="h-7 min-w-0 flex-1 rounded-lg border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-2 text-xs font-semibold text-[var(--app-text)] outline-none focus:border-brand-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => submitRename(group.name)}
|
||||
className="rounded-lg p-1.5 text-brand-600 hover:bg-[var(--app-hover)]"
|
||||
title="保存"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming('')
|
||||
setRenameDraft('')
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)]"
|
||||
title="取消"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-bold text-[var(--app-text)]">
|
||||
{group.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] text-[var(--app-muted)]">
|
||||
{bookCounts[group.name] ?? 0} 本
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRenaming(group.name)
|
||||
setRenameDraft(group.name)
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRemove(group.name)}
|
||||
className="rounded-lg p-1.5 text-[var(--app-muted)] hover:bg-red-500/10 hover:text-red-500"
|
||||
title="删除分组"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saving && (
|
||||
<div className="flex items-center justify-center gap-2 border-t border-[var(--app-border)] py-2 text-2xs text-[var(--app-muted)]">
|
||||
<Loader2 size={12} className="animate-spin" /> 正在保存…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -41,10 +41,10 @@ export const MediaCard = memo(function MediaCard({
|
||||
const card = (
|
||||
<div
|
||||
ref={ref}
|
||||
className="relative overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
className="relative flex h-full flex-col overflow-hidden rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-[0_1px_3px_rgba(0,0,0,0.01),0_1px_2px_rgba(0,0,0,0.015)] transition-all duration-300 hover:scale-[1.04] hover:-translate-y-1.5 hover:border-brand-500/40 hover:shadow-[0_12px_32px_var(--app-shadow)]"
|
||||
>
|
||||
{/* Poster Wrapper */}
|
||||
<div className="relative aspect-[2/3] w-full overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
<div className="relative aspect-[2/3] w-full shrink-0 overflow-hidden bg-[var(--app-panel-soft)]">
|
||||
{media.poster_url ? (
|
||||
<>
|
||||
{posterFit === 'contain' && (
|
||||
@@ -120,26 +120,34 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
{/* Media Metadata Info */}
|
||||
<div className={`space-y-1 border-t border-[var(--app-border)] bg-[var(--app-panel)] ${
|
||||
compact ? 'p-2' : 'p-4'
|
||||
compact ? 'p-2' : 'p-2.5 sm:p-4'
|
||||
}`}>
|
||||
<p className={`truncate font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact ? 'text-xs' : 'text-sm'
|
||||
{/* 手机端一张卡只有 ~100px 宽:标题放开到两行,并预留两行高度,
|
||||
这样同一行的卡片不会因为标题行数不同而参差不齐。 */}
|
||||
<p className={`font-bold text-[var(--app-text)] transition-colors duration-200 group-hover:text-brand-500 ${
|
||||
compact
|
||||
? 'truncate text-xs'
|
||||
: 'line-clamp-2 min-h-[2.75em] text-[13px] leading-snug sm:line-clamp-none sm:min-h-0 sm:truncate sm:text-sm sm:leading-normal'
|
||||
}`}>
|
||||
{media.title}
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-[11px] font-bold uppercase tracking-wider text-[var(--app-muted)]">
|
||||
<span>{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
<div className="flex min-h-[21px] items-center text-[10px] font-bold uppercase text-[var(--app-muted)] sm:min-h-0 sm:gap-x-2 sm:text-[11px] sm:tracking-wider">
|
||||
<span className="truncate">{media.year > 0 ? media.year : "未知年份"}</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-0.5 sm:gap-1.5">
|
||||
{/* 集数/版本数徽标:放在海报下方的信息行而不是压在画面上。海报底部通常
|
||||
自带片名,角标会盖住它(Game of Thrones / Breaking Bad 都很明显)。 */}
|
||||
{(count !== undefined && count > 1) || (count === undefined && versionCount > 1) ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="inline-flex items-center gap-0.5 whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1 py-0.5 text-[var(--app-subtle)] sm:gap-1 sm:px-1.5">
|
||||
<Layers size={10} className="shrink-0 text-[#c9954a]" />
|
||||
<span>{count !== undefined && count > 1 ? `${count} 集` : `${versionCount} 版本`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* 编码格式在小卡片上没有信息价值,手机上直接隐藏,避免挤掉集数徽标。 */}
|
||||
{media.video_codec && (
|
||||
<span className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)]">
|
||||
<span className="hidden whitespace-nowrap rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-1.5 py-0.5 text-[var(--app-subtle)] sm:inline-block">
|
||||
{media.video_codec}
|
||||
</span>
|
||||
)}
|
||||
@@ -151,8 +159,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className="group relative block w-full">
|
||||
<button type="button" onClick={onClick} className="block w-full text-left">
|
||||
<div className="group relative block h-full w-full">
|
||||
<button type="button" onClick={onClick} className="block h-full w-full text-left">
|
||||
{card}
|
||||
</button>
|
||||
{actionContent && (
|
||||
@@ -166,8 +174,8 @@ export const MediaCard = memo(function MediaCard({
|
||||
|
||||
if (actionContent) {
|
||||
return (
|
||||
<div className="group relative block">
|
||||
<Link to={href} state={linkState} className="block">
|
||||
<div className="group relative block h-full">
|
||||
<Link to={href} state={linkState} className="block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
<div className={ACTION_OVERLAY_CLASS}>
|
||||
@@ -178,7 +186,7 @@ export const MediaCard = memo(function MediaCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} state={linkState} className="group block">
|
||||
<Link to={href} state={linkState} className="group block h-full">
|
||||
{card}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, type SyntheticEvent } from 'react'
|
||||
import { BookOpen } from 'lucide-react'
|
||||
|
||||
import { isRenderableCover } from '../utils/coverUrl'
|
||||
import { readerCoverSrc } from '../utils/readerCover'
|
||||
|
||||
// 阅读子系统统一封面图。
|
||||
//
|
||||
// 书源返回的 cover_url 经常不是图片(空值、站点地址、data: 参数信封),
|
||||
// 直接塞进 <img> 会显示破图。这里统一处理:地址不是图片、或加载失败时,
|
||||
// 一律回退成占位图标。
|
||||
//
|
||||
// 另外,明文 http 的封面在 https 部署下会被混合内容策略拦掉,统一经后端图片
|
||||
// 代理下发(见 readerCoverSrc),否则这些封面会全部退化成占位图标。
|
||||
export default function ReaderBookCover({
|
||||
url,
|
||||
alt = '',
|
||||
@@ -25,7 +29,17 @@ export default function ReaderBookCover({
|
||||
setFailed(false)
|
||||
}, [url])
|
||||
|
||||
if (!isRenderableCover(url) || failed) {
|
||||
const src = readerCoverSrc(url)
|
||||
|
||||
// 后端图片代理抓不到上游时会回一张 1×1 透明 PNG(HTTP 200),<img> 因此不会
|
||||
// 触发 onError。书源在「没有封面」时可能返回源站首页这类非图片地址,代理同样
|
||||
// 只能给出这张占位像素——按加载失败处理,才会退回图标而不是留下一块空白。
|
||||
const handleLoad = (e: SyntheticEvent<HTMLImageElement>) => {
|
||||
const img = e.currentTarget
|
||||
if (img.naturalWidth <= 1 && img.naturalHeight <= 1) setFailed(true)
|
||||
}
|
||||
|
||||
if (!isRenderableCover(src) || failed) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<BookOpen size={iconSize} className="text-[var(--app-muted)]" />
|
||||
@@ -35,11 +49,12 @@ export default function ReaderBookCover({
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url ?? ''}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className={className}
|
||||
onLoad={handleLoad}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { hydrateReaderSettings, startReaderSettingsSync } from '../utils/readerSettingsSync'
|
||||
|
||||
// Route guard: redirects to /login when no token is present.
|
||||
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const userId = useAuthStore((s) => s.user?.id ?? null)
|
||||
const location = useLocation()
|
||||
|
||||
// 阅读器偏好按账号存在服务端:一进入受保护区域就开启回写订阅,并在
|
||||
// 登录 / 换账号时重新拉取(这里同时覆盖影视外壳和 /reader/* 独立布局)。
|
||||
useEffect(() => {
|
||||
startReaderSettingsSync()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (!userId) return
|
||||
void hydrateReaderSettings()
|
||||
}, [userId])
|
||||
|
||||
if (!token) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import { Virtuoso } from 'react-virtuoso'
|
||||
import clsx from 'clsx'
|
||||
|
||||
// 与 LibraryMediaSections 等处的海报网格保持同一套响应式列配置。
|
||||
// 手机上单卡只有约 100px 宽,间距收到 gap-3 给标题和徽标多留一点宽度。
|
||||
export const MEDIA_GRID_CLASS =
|
||||
'grid grid-cols-3 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
'grid grid-cols-3 gap-3 sm:gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8'
|
||||
|
||||
function getFallbackColumns(width: number): number {
|
||||
if (width >= 1536) return 8
|
||||
@@ -105,7 +106,7 @@ export function VirtualMediaGrid({
|
||||
itemContent={(rowIndex) => {
|
||||
const start = rowIndex * columns
|
||||
return (
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-4')}>
|
||||
<div className={clsx(MEDIA_GRID_CLASS, rowIndex < rowCount - 1 && 'pb-3 sm:pb-4')}>
|
||||
{Array.from({ length: columns }, (_, colIndex) => {
|
||||
const itemIndex = start + colIndex
|
||||
if (itemIndex >= totalCount) {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import type { BookGroup } from '../api/reader'
|
||||
import {
|
||||
ALL_GROUP_ID,
|
||||
assignBooksToGroup,
|
||||
attachBookToGroup,
|
||||
dedupeBookGroups,
|
||||
detachBooksFromGroup,
|
||||
loadBookGroups,
|
||||
normalizeBookGroups,
|
||||
normalizeBookGroupName,
|
||||
readCachedBookGroups,
|
||||
readSelectedGroupId,
|
||||
reorderBookGroups,
|
||||
resolveSelectedGroupId,
|
||||
saveBookGroups,
|
||||
writeCachedBookGroups,
|
||||
writeSelectedGroupId,
|
||||
} from '../utils/readerBookGroups'
|
||||
|
||||
export type UseBookGroupsResult = {
|
||||
/** 当前用户维护的分组(顺序即分组栏顺序)。 */
|
||||
groups: BookGroup[]
|
||||
/** 分组数据是否仍在首次加载中。 */
|
||||
loading: boolean
|
||||
/** 是否有写入在途(可用于禁用重复点击)。 */
|
||||
saving: boolean
|
||||
/** 接口不可用(例如旧后端)时降级为本地分组。 */
|
||||
loadError: boolean
|
||||
/** 当前选中的分组栏:ALL_GROUP_ID 表示「全部」。 */
|
||||
selectedGroupId: string
|
||||
setSelectedGroupId: (groupId: string) => void
|
||||
createGroup: (name: string) => Promise<string>
|
||||
renameGroup: (from: string, to: string) => Promise<void>
|
||||
/** 按给定分组名顺序重排分组栏(拖拽排序的落点)。 */
|
||||
reorderGroups: (orderedNames: string[]) => Promise<void>
|
||||
removeGroup: (name: string) => Promise<void>
|
||||
/** 覆盖某个分组下的书籍集合(保持传入顺序)。 */
|
||||
setGroupBooks: (name: string, bookIds: string[]) => Promise<void>
|
||||
/** 把一本书归到分组下;groupName 为空表示移出所有分组。 */
|
||||
assignBook: (bookId: string, groupName: string) => Promise<void>
|
||||
/** 批量归类;groupName 为空表示批量移出所有分组。一次写入。 */
|
||||
assignBooks: (bookIds: string[], groupName: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 书架分组的用户级读写(实现照搬影视模块的 useLibraryTags)。
|
||||
*
|
||||
* 写入采用「先乐观更新、串行提交、失败回滚」:分组是整份替换(`PUT`),
|
||||
* 并发请求可能让旧快照最后落库,因此所有写操作串行排队,每次发送的都是排队时的
|
||||
* 最新本地状态。接口不可用(旧后端 / 网络异常)时退回本地存储,分组栏依旧可用。
|
||||
*/
|
||||
export function useBookGroups(): UseBookGroupsResult {
|
||||
const [groups, setGroups] = useState<BookGroup[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
const [selectedGroupId, setSelectedGroupIdState] = useState(() => readSelectedGroupId())
|
||||
|
||||
const groupsRef = useRef<BookGroup[]>([])
|
||||
const selectedRef = useRef(selectedGroupId)
|
||||
const savedRef = useRef<BookGroup[]>([])
|
||||
const chainRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
const pendingRef = useRef(0)
|
||||
// 后端没有该接口(旧版本)时退化为仅本地存储,不再反复提示保存失败。
|
||||
const localOnlyRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
groupsRef.current = groups
|
||||
}, [groups])
|
||||
|
||||
useEffect(() => {
|
||||
selectedRef.current = selectedGroupId
|
||||
}, [selectedGroupId])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
loadBookGroups()
|
||||
.then((rows) => {
|
||||
if (cancelled) return
|
||||
groupsRef.current = rows
|
||||
savedRef.current = rows
|
||||
setGroups(rows)
|
||||
setLoadError(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
// 旧后端没有 /reader/book-groups:用本地缓存兜底,分组栏照常可用。
|
||||
const cached = readCachedBookGroups()
|
||||
groupsRef.current = cached
|
||||
savedRef.current = cached
|
||||
setGroups(cached)
|
||||
setLoadError(true)
|
||||
localOnlyRef.current = isMissingEndpoint(err)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 选中的分组可能被删除,落回「全部」以免分组栏空白。
|
||||
// 首次加载完成前 groups 为空集合,此时不能据此判定分组已被删除。
|
||||
useEffect(() => {
|
||||
if (loading) return
|
||||
const resolved = resolveSelectedGroupId(groups, selectedGroupId)
|
||||
if (resolved !== selectedGroupId) {
|
||||
selectedRef.current = resolved
|
||||
setSelectedGroupIdState(resolved)
|
||||
}
|
||||
}, [loading, groups, selectedGroupId])
|
||||
|
||||
const setSelectedGroupId = useCallback((groupId: string) => {
|
||||
const next = groupId && groupId !== ALL_GROUP_ID ? groupId : ALL_GROUP_ID
|
||||
selectedRef.current = next
|
||||
writeSelectedGroupId(next)
|
||||
setSelectedGroupIdState(next)
|
||||
}, [])
|
||||
|
||||
const mutate = useCallback((updater: (current: BookGroup[]) => BookGroup[]) => {
|
||||
const rollback = savedRef.current
|
||||
const next = dedupeBookGroups(normalizeBookGroups(updater(groupsRef.current)))
|
||||
groupsRef.current = next
|
||||
setGroups(next)
|
||||
|
||||
pendingRef.current += 1
|
||||
setSaving(true)
|
||||
const task = chainRef.current.then(async () => {
|
||||
// 发送排队时的最新本地状态:链上更早的写入已被后面的状态取代。
|
||||
const snapshot = groupsRef.current
|
||||
if (localOnlyRef.current) {
|
||||
if (groupsRef.current === snapshot) savedRef.current = snapshot
|
||||
writeCachedBookGroups(snapshot)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const saved = await saveBookGroups(snapshot)
|
||||
savedRef.current = saved
|
||||
setLoadError(false)
|
||||
if (groupsRef.current === snapshot) {
|
||||
groupsRef.current = saved
|
||||
setGroups(saved)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMissingEndpoint(err)) {
|
||||
// 旧后端不支持分组接口:本次修改保留在本地存储,分组栏照常可用。
|
||||
localOnlyRef.current = true
|
||||
savedRef.current = snapshot
|
||||
writeCachedBookGroups(snapshot)
|
||||
setLoadError(true)
|
||||
return
|
||||
}
|
||||
// 用最后一次成功保存的状态回滚,避免前端显示一份服务端并不存在的分组。
|
||||
groupsRef.current = rollback
|
||||
savedRef.current = rollback
|
||||
setGroups(rollback)
|
||||
setLoadError(true)
|
||||
toast.error('分组保存失败,请稍后重试')
|
||||
}
|
||||
})
|
||||
chainRef.current = task.catch(() => undefined)
|
||||
return task.finally(() => {
|
||||
pendingRef.current = Math.max(0, pendingRef.current - 1)
|
||||
if (pendingRef.current === 0) setSaving(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const createGroup = useCallback(
|
||||
async (name: string) => {
|
||||
const trimmed = normalizeBookGroupName(name)
|
||||
if (!trimmed) return ''
|
||||
if (groupsRef.current.some((group) => group.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||
return trimmed
|
||||
}
|
||||
await mutate((current) => [...current, { name: trimmed, book_ids: [] }])
|
||||
return groupsRef.current.some((group) => group.name === trimmed) ? trimmed : ''
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const renameGroup = useCallback(
|
||||
async (from: string, to: string) => {
|
||||
const trimmed = normalizeBookGroupName(to)
|
||||
if (!trimmed || trimmed === from) return
|
||||
await mutate((current) =>
|
||||
current.map((group) => (group.name === from ? { ...group, name: trimmed } : group)),
|
||||
)
|
||||
// 重命名后选中项要跟着换到新名字,否则分组栏会落回「全部」。
|
||||
if (selectedRef.current === from) setSelectedGroupId(trimmed)
|
||||
},
|
||||
[mutate, setSelectedGroupId],
|
||||
)
|
||||
|
||||
const reorderGroups = useCallback(
|
||||
async (orderedNames: string[]) => {
|
||||
await mutate((current) => reorderBookGroups(current, orderedNames))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const removeGroup = useCallback(
|
||||
async (name: string) => {
|
||||
await mutate((current) => current.filter((group) => group.name !== name))
|
||||
if (selectedRef.current === name) setSelectedGroupId(ALL_GROUP_ID)
|
||||
},
|
||||
[mutate, setSelectedGroupId],
|
||||
)
|
||||
|
||||
const setGroupBooks = useCallback(
|
||||
async (name: string, bookIds: string[]) => {
|
||||
await mutate((current) =>
|
||||
current.map((group) => (group.name === name ? { ...group, book_ids: bookIds } : group)),
|
||||
)
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignBook = useCallback(
|
||||
async (bookId: string, groupName: string) => {
|
||||
await mutate((current) =>
|
||||
groupName ? attachBookToGroup(current, bookId, groupName) : detachBooksFromGroup(current, [bookId]),
|
||||
)
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
const assignBooks = useCallback(
|
||||
async (bookIds: string[], groupName: string) => {
|
||||
await mutate((current) => assignBooksToGroup(current, bookIds, groupName))
|
||||
},
|
||||
[mutate],
|
||||
)
|
||||
|
||||
return {
|
||||
groups,
|
||||
loading,
|
||||
saving,
|
||||
loadError,
|
||||
selectedGroupId,
|
||||
setSelectedGroupId,
|
||||
createGroup,
|
||||
renameGroup,
|
||||
reorderGroups,
|
||||
removeGroup,
|
||||
setGroupBooks,
|
||||
assignBook,
|
||||
assignBooks,
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断错误是否说明后端没有这个接口(旧版本 / 反向代理未更新)。 */
|
||||
function isMissingEndpoint(err: unknown): boolean {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
return status === 404 || status === 405 || status === 501
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
|
||||
|
||||
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
|
||||
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
||||
//
|
||||
// 判定规则(和纵向滚动共存的关键):
|
||||
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
|
||||
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
|
||||
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
|
||||
// 短距离当点击处理,交给下面的分区点击。
|
||||
//
|
||||
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
|
||||
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
|
||||
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
|
||||
// (慢慢拖过去也是拖动)。
|
||||
//
|
||||
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
||||
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
||||
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
||||
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
||||
|
||||
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
|
||||
const DIRECTION_SLOP = 12
|
||||
|
||||
interface HorizontalSwipeOptions {
|
||||
/** 从左往右滑(手指向右移)。 */
|
||||
onSwipeRight: () => void
|
||||
/** 从右往左滑(手指向左移)。 */
|
||||
onSwipeLeft: () => void
|
||||
/** 触发翻页所需的最小水平位移(px)。 */
|
||||
minDistance?: number
|
||||
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
||||
dominance?: number
|
||||
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
|
||||
maxDuration?: number
|
||||
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
|
||||
onDrag?: (dx: number) => void
|
||||
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
|
||||
onDragEnd?: (dx: number) => void
|
||||
}
|
||||
|
||||
interface HorizontalSwipeHandlers {
|
||||
onTouchStart: (e: ReactTouchEvent) => void
|
||||
onTouchMove: (e: ReactTouchEvent) => void
|
||||
onTouchEnd: (e: ReactTouchEvent) => void
|
||||
onTouchCancel: () => void
|
||||
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
||||
consumeSwipe: () => boolean
|
||||
}
|
||||
|
||||
export function useHorizontalSwipe({
|
||||
onSwipeRight,
|
||||
onSwipeLeft,
|
||||
minDistance = 45,
|
||||
dominance = 1.2,
|
||||
maxDuration = 800,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
||||
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
||||
const consumedRef = useRef(false)
|
||||
/** 本次手势是否已判定为横滑并在跟手。 */
|
||||
const draggingRef = useRef(false)
|
||||
const lastDxRef = useRef(0)
|
||||
|
||||
const onTouchStart = useCallback((e: ReactTouchEvent) => {
|
||||
const t = e.touches[0]
|
||||
if (!t) return
|
||||
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
|
||||
consumedRef.current = false
|
||||
draggingRef.current = false
|
||||
lastDxRef.current = 0
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
const t = e.touches[0]
|
||||
if (!start || !t) return
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (!draggingRef.current) {
|
||||
if (!onDrag) return
|
||||
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
|
||||
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) {
|
||||
startRef.current = null
|
||||
return
|
||||
}
|
||||
draggingRef.current = true
|
||||
}
|
||||
lastDxRef.current = dx
|
||||
onDrag?.(dx)
|
||||
},
|
||||
[onDrag, dominance],
|
||||
)
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
startRef.current = null
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false
|
||||
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
|
||||
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
|
||||
consumedRef.current = true
|
||||
const t = e.changedTouches[0]
|
||||
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
|
||||
const dx = start && t ? t.clientX - start.x : lastDxRef.current
|
||||
onDragEnd?.(dx)
|
||||
return
|
||||
}
|
||||
const t = e.changedTouches[0]
|
||||
if (!start || !t) return
|
||||
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (Date.now() - start.t > maxDuration) return
|
||||
if (Math.abs(dx) < minDistance) return
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) return
|
||||
|
||||
consumedRef.current = true
|
||||
if (dx > 0) onSwipeRight()
|
||||
else onSwipeLeft()
|
||||
},
|
||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
|
||||
)
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
startRef.current = null
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
|
||||
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
|
||||
onDragEnd?.(0)
|
||||
}, [onDragEnd])
|
||||
|
||||
const consumeSwipe = useCallback(() => {
|
||||
if (!consumedRef.current) return false
|
||||
consumedRef.current = false
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user