Compare commits

...

56 Commits

Author SHA1 Message Date
truewhile 6864a9c7f1 优化 2026-10-12 23:01:46 +08:00
truewhile 079b3ff063 优化兼容性 2026-10-11 23:44:11 +08:00
truewhile 3c30c64a39 优化 2026-10-11 21:44:20 +08:00
truewhile 6186c8b848 优化 2026-10-11 21:29:19 +08:00
truewhile 0db8698fce 优化 2026-10-10 13:49:13 +08:00
truewhile 42703c6760 优化 2026-10-10 12:00:33 +08:00
truewhile 4ed2edab5b 优化,阅读问题处理 2026-10-10 11:26:47 +08:00
truewhile 374ee28f01 添加点击2次全屏的功能 2026-10-10 09:54:01 +08:00
truewhile 3a6b8db0b3 添加全屏功能 2026-10-08 23:59:35 +08:00
truewhile b50cb7c31e 优化 2026-10-08 23:43:28 +08:00
truewhile 5f4b613bde 修复 115 云转码播放中途断流后直接掉到本地转码
115 云 HLS 会话只存在服务端内存里:服务重新部署、会话过期(30 分钟无活动)、
上游分片地址失效,都会让播放中的客户端在后续分片上拿到 404/410。原实现此时
直接退回本地转码,而低配宿主机本地转码追不上播放速度(实测 2 vCPU 只有
0.62x),用户看到的就是播放报废。

- 云 HLS 中途 fatal 时先重建会话续播(10 分钟内最多 3 次),仍失败才退回本地
- manifestLoadingMaxRetry 1 -> 4:master.m3u8 每次都要现调 115 接口,一次抖动
  不该直接判死刑
- 播放列表重写改用上游地址哈希做 key:原来用自增序号,每次重写 entries 就膨胀
  一轮、分片编号漂移(实测 762 段列表重写几次后 key 涨到 e2288..e3049)
- 会话条目数加上限,异常播放列表不会无限占用内存
- 客户端断开(seek / 切清晰度取消在途请求)不再记成 502,改记 499 与 nginx
  对齐;会话失效返回带 code 的 404/410,便于前端判断需要重建会话
- 修正进度/时长上报:云 HLS 时间轴起点恒为 0,不应再叠加 hlsStartSec(历史上
  把 7624s 的片子记成 8346s 并标成「已看完」)
- 切回直连 / 原画时保住播放位置:HLS 卸载会把 currentTime 归零,必须在切换前
  先记下目标位置
2026-10-08 22:52:31 +08:00
truewhile 67ef9c95be 优化 2026-10-08 22:18:16 +08:00
truewhile 2bf1b27e23 优化翻页 2026-10-08 20:53:39 +08:00
truewhile 48a1c36a7a 优化 2026-10-08 20:13:46 +08:00
truewhile d7665e4ef0 优化 2026-10-08 17:42:07 +08:00
truewhile 99916c0b97 优化 2026-10-08 13:48:06 +08:00
truewhile 6adc3cb3b2 删除文件 2026-10-08 11:27:28 +08:00
truewhile 2b2dc697ce bug处理 2026-10-08 11:13:10 +08:00
truewhile 8bfdd75d47 优化 2026-10-04 22:17:19 +08:00
truewhile 1a3248f4bb 优化 2026-10-04 21:31:09 +08:00
truewhile 0be7f8b97e 优化 2026-10-04 17:50:26 +08:00
truewhile 6984158f24 修复段评往下滑一直闪:评论接口分页改游标,页面还在按 page 翻页
上游评论页按 page=N 翻页,但列表接口 /para_review 早已改成游标分页(响应给
next_cursor,请求要带 cursor=)。接口不认识 page,于是每次「加载更多」都原样
返回第一页:一条新评论都加不进去,has_more 又始终是 true,评论页便一遍遍触发
加载更多、反复闪骨架屏——用户看到的就是段评往下滑一闪一闪,评论一条没多。

评论页是上游 HTML 改不了,于是在承载页面的接口代理里做最小适配:
  - 请求带 page=N(N>1)且有上一页给的游标时,换成 cursor= 请求;
  - 某页返回的 comment_id 全是已经见过的,就把 has_more 改成 false 收尾,
    避免页面在「加载更多 → 没有新内容」之间空转。

只认路径以 /para_review 结尾、带 page 且不带 cursor 的请求,其余原样透传。
2026-10-04 16:28:13 +08:00
truewhile d545ff1a7b 修复段评打开后没有评论:承载页面丢失原地址参数
段评气泡上的数字来自正文里的 <comment count>,但点开后的评论页跑在
「/api/reader/browser/page?id=..&s=..」这个承载地址上,页面看到的
location.search 只剩我们自己的 id/s,原地址的 item_id/para/source 全丢了。
评论页正是靠这几个参数去请求 /para_review,于是按缺参数请求,列表永远为空:
外面气泡显示 99,点开一条段评都没有。

承载地址改为把原地址的 query 原样拼在签名参数之后(页面看到的 search 与原站
一致);id/s 放最前,同名的原参数顶不掉我们的签名参数。
2026-10-04 13:53:44 +08:00
truewhile 227e0a9197 优化 2026-10-04 13:29:39 +08:00
truewhile e85df5fb6b 优化 2026-10-04 12:35:33 +08:00
truewhile 6656f9c398 优化 2026-10-04 11:46:17 +08:00
truewhile dacfec7492 优化,bug处理 2026-10-04 01:21:07 +08:00
truewhile b0f1ab39b9 优化登录,优化阅读 2026-10-03 21:00:16 +08:00
truewhile 5242d54a06 优化添加段评内容 2026-10-03 18:41:20 +08:00
truewhile e961969207 优化 2026-10-03 18:25:13 +08:00
truewhile b8b02cb3a7 优化 2026-10-03 14:21:07 +08:00
truewhile 60cfad47e5 优化 2026-10-03 14:05:24 +08:00
truewhile 47c8bfdd6c bug处理 2026-10-03 13:38:18 +08:00
truewhile ce3baee8f1 优化 2026-10-02 23:54:37 +08:00
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
truewhile c4e9926352 优化 2026-10-02 18:44:07 +08:00
truewhile f3b88fad7c 优化 2026-10-02 17:56:16 +08:00
truewhile 2c6646beea bug处理 2026-10-02 15:14:36 +08:00
truewhile 83ec494a36 bug处理 2026-10-02 13:14:29 +08:00
truewhile b8648f0049 bug处理 2026-10-02 12:02:29 +08:00
truewhile 5acfa6292a 优化 2026-10-02 11:05:59 +08:00
truewhile 42343212c5 Merge pull request #37 from truewhile/feature/reading
优化
2026-10-01 23:30:58 +08:00
truewhile 6584519bd8 优化 2026-10-01 23:29:49 +08:00
truewhile 565a55b194 Merge pull request #36 from truewhile/feature/reading
添加阅读功能
2026-10-01 22:19:39 +08:00
truewhile 9036ad04e7 镜像问题处理 2026-10-01 22:18:37 +08:00
truewhile ce4319ec25 优化阅读功能 2026-10-01 21:57:25 +08:00
truewhile c1ff55ffbf 开发 2026-09-30 19:22:24 +08:00
truewhile fa41936095 feat(reader): P3+P4——音频源与漫画/图片源全链路支持
后端:
- /api/reader/media 签名媒体代理:HMAC-SHA256(jwtSecret, bookID|url) 防开放
  中转滥用;免 JWT(audio/img 元素带不了头)但需有效签名+书籍存在
- 携书源级请求头与 Referer(防盗链)、Range 透传(音频拖动进度 206)、
  m3u8 播放列表分片/密钥地址改写为签名代理(hls.js 无感续播)
- 音频/图片地址按最终页面 URL 绝对化,imageStyle 透传;
  GetContentForBook 将 tracks/images 统一改写为代理地址
前端:
- ReaderAudioPanel(仿 ReadAloudDialog):hls.js 播 m3u8 / <audio> 直链,
  上一章/播放暂停/下一章、进度条拖动、倍速 0.75–2x、按秒记忆进度、
  播完自动下一章、点背景呼出菜单
- ReaderComic:上下滚动 / 左右单页双模式(复用翻页/滚动切换)、
  图片懒加载+失败占位、滚动模式左右点击滚屏/中间呼菜单、
  单页模式点击区翻页、菜单进度条按图片序号、按图片序号记忆进度
- 菜单进度条按内容类型适配(文本页/音频章节/漫画图片序号)
2026-09-30 17:12:16 +08:00
truewhile 9cc21a47c5 feat(reader): P2 收尾——冒烟 CLI、书架正文链路、替换净化规则
- SmokeChain 结构化全链路冒烟(搜索→详情→目录→正文,分阶段失败定位),
  SmokeSource 支持不落库直测;Debug 接口改为结构化日志输出
- cmd/reader-smoke:批量书源冒烟 CLI(-file/-url 导入、并发、
  各阶段通过率汇总、-json 完整报告)
- GetContentForBook:书架维度正文(章节缓存 + 书源 replaceRegex +
  用户替换净化规则),前端阅读器切换到该接口
- 替换净化规则 CRUD(后端 + /reader/replace 前端页),用户规则
  按 scope/excludeScope 书名过滤、正则带匹配超时防回溯挂死
- rule.ApplyUserReplace 导出(regexp2 MatchTimeout)
2026-09-30 16:55:52 +08:00
truewhile 5976a8d310 feat(reader): P2——goja JS 引擎接入 + Go 原生 java.* 桥接
- rule/goja.go:纯 Go JS 运行时(goja),脚本编译缓存 + 超时中断(默认10s),
  绑定集合对齐 legado evalJS(java/cookie/cache/source/book/result/baseUrl/
  chapter/title/src/page/key)
- rule/bridge.go:java 对象函数名与 legado JsExtensions 一一对应(存量书源
  硬编码这些名字),全部 Go 原生实现:
  网络 ajax/ajaxAll/connect/get/post/head(经 MeBox HTTP 客户端,响应对象
  带 body()/code()/url()/header());规则回调 getString/getStringList/
  getElements/put/get(桥回当前解析器);编解码 base64/hex/md5(16/32)/
  digest/HMac/encodeURI/timeFormat(SimpleDateFormat 转 Go 布局)
- 对称加密:createSymmetricCrypto + aes/des/3des 全家桶,transformation
  三段式解析(AES/DES/3DES × CBC/ECB/CTR/OFB/CFB × PKCS5/7/Zero/NoPadding,
  ECB 手工块循环),密钥长度自动补齐,decryptStr 自动识别 Base64/Hex
- get(url,headers) 与 get(key) 按 arity 分发(对齐 Kotlin 重载)
- 浏览器/文件/压缩包类函数明确抛不支持(服务端沙箱边界)
- URL 规则 JS:analyzeJs(@result 链式)、{{js}} 内嵌、option js/bodyJs
- execute 记录 Set-Cookie 进 cookie jar(java/cookie.getCookie 可读)
- 测试:编码/摘要/AES 向量/CBC+ECB 往返/ajax httptest/URL JS/沙箱边界,
  全部通过;修复 java.get 双注册、测试缺 SetContent 与 md5Encode16 期望值
2026-09-30 16:36:32 +08:00
truewhile d0b4255aa8 feat(reader): P1 前端——首页影视/阅读切换 + 书架/搜索/书源/详情/阅读器
- 首页顶部「影视/阅读」分段切换(zustand 持久化,阅读模式直渲染书架)
- /reader/* 独立全屏路由(不套影视 Layout):
  书架(响应式网格 3→8 列)、搜索页(结果流+失败书源明细)、
  书源管理(导入/启停/删除/快速调试日志)、详情页(目录+加书架)
- 阅读器仿 legado ReadMenu:6 套内置主题(日/夜色值对齐 readConfig.json)、
  CSS 多栏分页 + 平移翻页/滚动双模式、三区点击 + 键盘翻页(桌面)、
  上下章、目录抽屉(Virtuoso 虚拟滚动)、字号/行距/段距调节、
  进度记忆(服务端同步 + 下一章预取)
- 桌面兼容:内容列 900px 限宽居中、鼠标悬停态、键盘快捷键
- 后端:详情/目录/正文接口支持 source_url 定位书源(书架链路)
2026-09-30 16:07:28 +08:00
truewhile b8e6418326 feat(reader): legado 书源规则引擎 Go 移植 + 阅读 P0 后端
- internal/service/reader/rule/:逐方法移植 legado analyzeRule 包
  (AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/
  AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer),JS 规则留 P2 接入点
- 书源导入(JSON数组/对象/Base64/URL)、多源聚合搜索(legado 四档
  排序合并)、详情/目录/正文(nextContentUrl 翻页合并)
- 数据模型 4 表注册迁移;/api/reader/* 路由组(书源/搜索/书架/进度/调试)
- 单测 + httptest 全链路端到端测试
- docs/reader-ui-spec.md:legado UI 交互仿制规格(供 P1 前端使用)
2026-09-30 15:41:58 +08:00
truewhile c84f2604ed docs(reader): 阅读(legado 书源兼容)功能实施计划 2026-09-30 14:39:51 +08:00
truewhile 1e6909ddbf 优化播放 2026-09-30 11:20:27 +08:00
truewhile 8136e6ecb1 fix(emby): 电影条目不下发空的 SeasonId/SeriesId,修复小幻影视起播失败
小幻影视(RodelPlayer)2.2610 会把电影条目的 SeasonId 空串原样传进自己的
播放准备逻辑并抛 ArgumentException(Parameter 'seasonId'),客户端日志里
表现为 failedStage=PreparationFailed、playback generation null,也就是
点播放立刻「播放失败」;剧集条目的 SeasonId 非空,所以同一台设备放剧集正常。

Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键)而不是下发空串。
这里对非剧集条目省略 SeasonId/SeriesId/SeasonName/SeriesName(顺带去掉电影被
标成「特别篇」的 SeasonName),剧集则保留真实季/剧信息、只清空值。

同时回退上一版针对 115 直链 UA 绑定的同源一跳改动:客户端日志显示该客户端的
播放器层与 API 层 UA 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
2026-09-28 22:51:40 +08:00
truewhile 1d73374fff fix(emby): 网盘直链绑定 UA,第三方播放器保留同源一跳
115 直链与换取时的 User-Agent 逐字符绑定,换错一律 403。服务端换链用的是邮件客户端 API 层的 UA,而真正拉流的是播放器组件,RodelPlayer 2.2610 两层 UA 不一致(API 层带系统后缀)后会拿到 403 直链,表现为播放失败。非浏览器客户端改为 302 到同源 strm 端点,由播放器自己换链;浏览器仍走服务端换链一跳。
2026-09-28 22:29:36 +08:00
274 changed files with 47469 additions and 518 deletions
-2
View File
@@ -1,2 +0,0 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+1 -1
View File
@@ -187,7 +187,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: Download web/dist
uses: actions/download-artifact@v4
+33 -26
View File
@@ -4,10 +4,12 @@
# 产出:
# 1. 前端 + 后端编译验证(go vet / go test / go build)
# 2. 多平台可执行二进制 artifact(linux/amd64、linux/arm64、windows/amd64)
# 3. ghcr.io/{owner}/mebox:beta 多架构 Docker 镜像(linux/amd64 + linux/arm64)
# 3. ghcr.io/{owner}/mebox 多架构 Docker 镜像(linux/amd64 + linux/arm64),
# 标签:beta(滚动)+ {基版本}-beta.{短 SHA}(本次构建,只增不改)
#
# 与 main 分支的发布流(Auto-docker-publish.yml)隔离:beta 不做版本递增、
# 不打 release tag,只构建带 -beta 标识的产物供测试。
# 与 main 分支发布流(Auto-docker-publish.yml)的关系:镜像的构建方式、标签策略
# 和基版本号来源(version 分支)与发布流保持一致,区别是 beta 不做版本递增、
# 不打 release tag、不发布 GitHub Release、不部署服务器。
name: Beta Build
@@ -22,9 +24,6 @@ permissions:
contents: read
packages: write
env:
BETA_VERSION_PREFIX: beta
jobs:
# ─────────────────────────────────────────────────────────────────────────────
# 1) 编译验证 + 多平台二进制产物
@@ -32,17 +31,24 @@ jobs:
test-and-build:
name: Test & build artifacts
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
binary_version: ${{ steps.version.outputs.binary_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 基版本号与发布流同源:读 version 分支上由 main 发布流水线维护的 VERSION。
# beta 只读不自增,避免与 main 的发布流程争抢版本号。
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
git fetch origin version || true
BASE_VERSION=$(git show FETCH_HEAD:VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
echo "binary_version=mebox-v${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
# The binary embeds the SPA (web/dist) via go:embed, so dist must exist
# before the Go toolchain touches the web package.
@@ -59,7 +65,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
- name: go vet
@@ -71,13 +77,13 @@ jobs:
- name: go build (host)
run: go build ./...
# 多平台可执行文件(嵌入刚构建的 web/dist)
# 多平台可执行文件(嵌入刚构建的 web/dist),版本号写入口径与发布流一致
- name: Build linux/amd64
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-amd64 ./cmd/server
- name: Build linux/arm64
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
run: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-linux-arm64 ./cmd/server
- name: Build windows/amd64
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.full_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
run: CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w -H=windowsgui -X main.version=${{ steps.version.outputs.binary_version }}" -o dist/mebox-beta-windows-amd64.exe ./cmd/server
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -87,7 +93,8 @@ jobs:
if-no-files-found: error
# ─────────────────────────────────────────────────────────────────────────────
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta)
# 2) Beta Docker 镜像(ghcr.io/{owner}/mebox:beta + :{基版本}-beta.{短 SHA})
# 构建步骤与 Auto-docker-publish.yml 的 build-image 一致,只是不带 deploy。
# ─────────────────────────────────────────────────────────────────────────────
docker-beta:
name: Build & push beta Docker image
@@ -98,13 +105,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Resolve beta version
id: version
run: |
BASE_VERSION=$(cat VERSION 2>/dev/null || echo "0.0.0")
SHA_SHORT=${GITHUB_SHA:0:7}
echo "full_version=${BASE_VERSION}-beta.${SHA_SHORT}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
@@ -115,6 +115,15 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/mebox
tags: |
type=raw,value=beta
type=raw,value=${{ needs.test-and-build.outputs.version }}
- name: Build & push
uses: docker/build-push-action@v6
with:
@@ -123,11 +132,9 @@ jobs:
push: true
provenance: false
sbom: false
tags: ghcr.io/${{ github.repository_owner }}/mebox:beta
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.repository }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.full_version }}
VERSION=${{ needs.test-and-build.outputs.binary_version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
- uses: actions/setup-go@v5
with:
go-version: '1.25'
go-version: '1.26'
cache: true
# The binary embeds the SPA (web/dist) via go:embed, so the dist must exist
+2 -1
View File
@@ -80,4 +80,5 @@ media/
tools/
verify-cache/
verify-data/
.zcode/
.zcode/
.tmp-src
+114
View File
@@ -0,0 +1,114 @@
# Binaries
bin/
*.exe
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
*.out
# Go workspace
go.work
# Dependency directories
node_modules/
# Build artifacts
web/dist/
web/.vite/
web/coverage/
web/tsconfig.tsbuildinfo
dist-release/
# Data / runtime
data/
cache/
logs/
.tmp-deploy-data/
.tmp-deploy-smoke-data/
.tmp-deploy-smoke-cache/
.tmp-deploy-cache/
.tmp-deploy-server.*
.tmp-live-server.*
.mebox.pid
*.log
*.db
*.db-journal
*.db-shm
*.db-wal
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Local configs (keep examples)
config/secrets.yaml
config.yaml
# WorkBuddy workspace (local AI assistant memory)
.workbuddy/
# Editor backups
*~
.tmp_*
# Runtime / local-only artifacts (清理补充)
.tmp/
.tmp-live-backups/
.tmp-*
.codex-*
.codex/
downloads/
media/
*.pid
# 本地开发运行产物
.agents/
.claude/
.dev-cache/
.dev-data/
.dev-logs/
tools/
verify-cache/
verify-data/
.zcode/
# ===== ↑ 以上同步自 .gitignore(「从 .gitignore 同步」只重写以上部分)=====
.git/
.hg/
.svn/
bower_components/
jspm_packages/
__pycache__/
site-packages/
venv/
coverage/
htmlcov/
lcov-report/
cmakefiles/
cmake-build-*/
bazel-*/
pods/
deriveddata/
storybook-static/
playwright-report/
test-results/
allure-results/
allure-report/
cdk.out/
*.egg-info/
*.dist-info/
eggs/
pip-wheel-metadata/
wheels/
# ----- ↑ 以上为 ZCode 默认排除规则(自定义规则请写在本行下方,不会被同步/恢复改动)-----
# 自定义规则写在下方(本行提示可删除)
+8 -5
View File
@@ -3,7 +3,7 @@
# Multi-architecture build for MeBox.
#
# Stage 1 (frontend) : Node 20.19+ -> static SPA bundle
# Stage 2 (backend) : Go 1.25 -> single static binary (CGO_ENABLED=0)
# Stage 2 (backend) : Go 1.26 -> single static binary (CGO_ENABLED=0)
# Stage 3 (runtime) : Alpine 3.23 -> ffmpeg + tzdata + non-root user
#
# Build:
@@ -25,7 +25,7 @@ COPY web/ .
RUN npm run build
# ---- Stage 2: backend (cross-compiled to TARGETPLATFORM) -------------------
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS backend
FROM --platform=$BUILDPLATFORM golang:1.26-alpine AS backend
ARG TARGETOS
ARG TARGETARCH
ARG GOPROXY=https://proxy.golang.org,direct
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
# .http_scheme 选择协议并在失败时回退,无需用户改配置。
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD ["/usr/local/bin/mebox-healthcheck"]
# Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without
# rewriting /etc/passwd or /etc/group on every container start.
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck
RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck
CMD ["/entrypoint.sh"]
+1 -1
View File
@@ -22,7 +22,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+1 -1
View File
@@ -21,7 +21,7 @@
</p>
<p align="center">
<img alt="Go" src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="Go" src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" />
<img alt="React" src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=111827" />
<img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" />
+206
View File
@@ -0,0 +1,206 @@
// reader-smoke 是阅读书源兼容性冒烟工具:
// 对批量书源逐个跑「搜索 → 详情 → 目录 → 正文」全链路,输出兼容率报告。
//
// 用法:
//
// go run ./cmd/reader-smoke -file sources.json -key 斗破苍穹 -c 8
// go run ./cmd/reader-smoke -url https://example.com/sources.json -json > report.json
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service/reader"
)
func main() {
file := flag.String("file", "", "书源文件路径(JSON 数组/对象/Base64/每行一个)")
urlFlag := flag.String("url", "", "书源网络地址(与 -file 二选一)")
key := flag.String("key", "斗破苍穹", "搜索关键词")
concurrency := flag.Int("c", 4, "并发数")
timeout := flag.Int("timeout", 90, "单源全链路超时(秒)")
jsonOut := flag.Bool("json", false, "输出完整 JSON 报告(追加在汇总后)")
flag.Parse()
payload := ""
switch {
case *file != "":
data, err := os.ReadFile(*file)
if err != nil {
fatal("读取文件失败: %v", err)
}
payload = string(data)
case *urlFlag != "":
client := helper.NewSiteHTTPClient(30, true)
req, err := http.NewRequest("GET", *urlFlag, nil)
if err != nil {
fatal("构造请求失败: %v", err)
}
for k, v := range helper.HTTPHeaderPresets() {
req.Header.Set(k, v)
}
resp, err := client.Do(req)
if err != nil {
fatal("拉取书源失败: %v", err)
}
defer resp.Body.Close()
var sb strings.Builder
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
sb.Write(buf[:n])
if err != nil {
break
}
}
payload = sb.String()
default:
fatal("需要 -file 或 -url 指定书源来源")
}
sources := reader.ParseSourcePayload(payload)
if len(sources) == 0 {
fatal("未从输入中识别到书源")
}
// 书源文件缓存根目录: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))
sem := make(chan struct{}, max(1, *concurrency))
var wg sync.WaitGroup
for i, raw := range sources {
wg.Add(1)
sem <- struct{}{}
go func(i int, raw string) {
defer wg.Done()
defer func() { <-sem }()
ctxSrc, cancel := context.WithTimeout(ctx, time.Duration(*timeout)*time.Second)
defer cancel()
res := svc.SmokeSource(ctxSrc, raw, *key)
results[i] = res
status := "✓"
if !res.OK {
status = "✗"
}
fmt.Fprintf(os.Stderr, "%s %-24s [%s] hits=%d chapters=%d content=%d %s\n",
status, truncate(res.SourceName, 24), stageCN(res), res.SearchHits, res.Chapters, res.ContentLen, res.Error)
}(i, raw)
}
wg.Wait()
// 汇总
var searchOK, infoOK, tocOK, contentOK, allOK int
failedAt := map[string]int{}
for _, r := range results {
if r == nil {
continue
}
switch r.FailedAt {
case "":
allOK++
searchOK++
infoOK++
tocOK++
contentOK++
case "search":
failedAt["search"]++
case "info":
searchOK++
failedAt["info"]++
case "toc":
searchOK++
infoOK++
failedAt["toc"]++
case "content":
searchOK++
infoOK++
tocOK++
failedAt["content"]++
}
}
n := len(results)
pct := func(v int) string {
if n == 0 {
return "0%"
}
return fmt.Sprintf("%.1f%%", float64(v)/float64(n)*100)
}
fmt.Printf("\n==== 冒烟报告 ====\n")
fmt.Printf("书源总数: %d 关键词: %s\n", n, *key)
fmt.Printf("搜索通过: %d (%s)\n", searchOK, pct(searchOK))
fmt.Printf("详情通过: %d (%s)\n", infoOK, pct(infoOK))
fmt.Printf("目录通过: %d (%s)\n", tocOK, pct(tocOK))
fmt.Printf("正文通过: %d (%s)\n", contentOK, pct(contentOK))
fmt.Printf("全链路通过: %d (%s)\n", allOK, pct(allOK))
for _, stage := range []string{"search", "info", "toc", "content"} {
if failedAt[stage] > 0 {
fmt.Printf(" 失败于 %s: %d\n", stageCN(&reader.SmokeChainResult{FailedAt: stage}), failedAt[stage])
}
}
if *jsonOut {
out, err := json.MarshalIndent(results, "", " ")
if err != nil {
fatal("序列化报告失败: %v", err)
}
fmt.Println(string(out))
}
}
func stageCN(r *reader.SmokeChainResult) string {
switch r.FailedAt {
case "":
return "完成"
case "search":
return "搜索"
case "info":
return "详情"
case "toc":
return "目录"
case "content":
return "正文"
case "parse":
return "解析"
default:
return r.FailedAt
}
}
func truncate(s string, n int) string {
rs := []rune(strings.TrimSpace(s))
if len(rs) <= n {
if len(rs) == 0 {
return "(未命名)"
}
return string(rs)
}
return string(rs[:n]) + "…"
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, "reader-smoke: "+format+"\n", args...)
os.Exit(1)
}
+1 -1
View File
@@ -88,7 +88,7 @@ func (a *application) start() error {
a.logger.Error("database not ready", zap.Error(err))
return fmt.Errorf("database not ready: %w", err)
}
if err := database.AutoMigrate(db); err != nil {
if err := database.AutoMigrateWithConfig(db, a.cfg, a.logger); err != nil {
a.logger.Error("auto-migrate failed", zap.Error(err))
return fmt.Errorf("auto-migrate failed: %w", err)
}
+129
View File
@@ -0,0 +1,129 @@
package main
import (
"bufio"
"net"
"sync"
"time"
)
// 开启 HTTPS 后,同一个端口会同时面对两类连接:
//
// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务;
// - 明确访问 http:// 的客户端——以前只会撞出一句
// "Client sent an HTTP request to an HTTPS server.",用户看不懂。
//
// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节:
// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。
// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被
// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。
const tlsRecordHandshake = 0x16
// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住
// goroutine。分流器按实例持有该值,便于测试用更短的超时。
const defaultPeekTimeout = 10 * time.Second
// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。
type splitListener struct {
ln net.Listener
tlsCh chan net.Conn
plainCh chan net.Conn
quit chan struct{}
once sync.Once
peek time.Duration
}
func newSplitListener(ln net.Listener) *splitListener {
return newSplitListenerWithTimeout(ln, defaultPeekTimeout)
}
func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener {
s := &splitListener{
ln: ln,
tlsCh: make(chan net.Conn),
plainCh: make(chan net.Conn),
quit: make(chan struct{}),
peek: peek,
}
go s.acceptLoop()
return s
}
// tlsListener 返回只收到 TLS 连接的 listener。
func (s *splitListener) tlsListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit}
}
// plainListener 返回只收到明文 HTTP 连接的 listener。
func (s *splitListener) plainListener() net.Listener {
return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit}
}
func (s *splitListener) acceptLoop() {
for {
conn, err := s.ln.Accept()
if err != nil {
// 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed,
// 由持有它们的 http.Server 自行收尾。
s.stop()
return
}
go s.dispatch(conn)
}
}
func (s *splitListener) dispatch(conn net.Conn) {
br := bufio.NewReader(conn)
_ = conn.SetReadDeadline(time.Now().Add(s.peek))
first, err := br.Peek(1)
_ = conn.SetReadDeadline(time.Time{})
if err != nil {
_ = conn.Close()
return
}
peeked := &peekedConn{Conn: conn, r: br}
ch := s.plainCh
if first[0] == tlsRecordHandshake {
ch = s.tlsCh
}
select {
case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭
case <-s.quit:
_ = conn.Close()
}
}
func (s *splitListener) stop() {
s.once.Do(func() { close(s.quit) })
}
// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。
type channelListener struct {
addr net.Addr
conns <-chan net.Conn
quit <-chan struct{}
}
func (l *channelListener) Accept() (net.Conn, error) {
select {
case conn := <-l.conns:
return conn, nil
case <-l.quit:
return nil, net.ErrClosed
}
}
// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭,
// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。
func (l *channelListener) Close() error { return nil }
func (l *channelListener) Addr() net.Addr { return l.addr }
// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"io"
"net"
"net/http"
"testing"
"time"
)
func TestSplitListenerRoutesByFirstByte(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
split := newSplitListener(raw)
addr := raw.Addr().String()
// TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。
handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c}
tlsClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = tlsClient.Close() }()
if _, err := tlsClient.Write(handshake); err != nil {
t.Fatal(err)
}
tlsConn := <-split.tlsCh
defer func() { _ = tlsConn.Close() }()
got := make([]byte, len(handshake))
if _, err := io.ReadFull(tlsConn, got); err != nil {
t.Fatalf("read peeked tls bytes: %v", err)
}
if string(got) != string(handshake) {
t.Fatalf("tls bytes = %v, want %v", got, handshake)
}
// 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。
request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n"
plainClient, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainClient.Close() }()
if _, err := plainClient.Write([]byte(request)); err != nil {
t.Fatal(err)
}
plainConn := <-split.plainCh
defer func() { _ = plainConn.Close() }()
head := make([]byte, len("GET /"))
if _, err := io.ReadFull(plainConn, head); err != nil {
t.Fatalf("read peeked plain bytes: %v", err)
}
if string(head) != request[:len(head)] {
t.Fatalf("plain bytes = %q, want prefix of %q", head, request)
}
// 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。
_ = raw.Close()
for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} {
deadline := time.Now().Add(3 * time.Second)
for {
_, err := ln.Accept()
if err == net.ErrClosed {
break
}
if time.Now().After(deadline) {
t.Fatalf("Accept after close = %v, want net.ErrClosed", err)
}
time.Sleep(10 * time.Millisecond)
}
}
}
// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。
func TestSplitListenerDropsIdleConnection(t *testing.T) {
raw, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = raw.Close() }()
split := newSplitListenerWithTimeout(raw, 200*time.Millisecond)
conn, err := net.Dial("tcp", raw.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
select {
case c := <-split.plainCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case c := <-split.tlsCh:
_ = c.Close()
t.Fatal("idle connection should not be dispatched")
case <-time.After(time.Second):
}
}
func TestHTTPSRedirectTarget(t *testing.T) {
cases := []struct {
name string
host string
rawURL string
want string
wantErr bool
}{
{name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"},
{name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"},
{name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"},
{name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"},
{name: "rejects empty host", host: "", rawURL: "/", wantErr: true},
{name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true},
{name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil)
if err != nil {
t.Fatal(err)
}
req.Host = tc.host
if host := redirectHostHeader(req); host == "" {
if !tc.wantErr {
t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host)
}
return
}
if tc.wantErr {
t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host)
}
if got := httpsRedirectURL(tc.host, req); got != tc.want {
t.Fatalf("redirect target = %q, want %q", got, tc.want)
}
})
}
}
+123 -18
View File
@@ -7,6 +7,8 @@ import (
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -17,6 +19,14 @@ import (
"github.com/truewhile/MeBox/internal/service"
)
// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。
//
// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启
// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出
// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。
// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。
const httpSchemeFileName = ".http_scheme"
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
type tlsPair struct {
cert tls.Certificate
@@ -35,6 +45,9 @@ type tlsPair struct {
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
//
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
//
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
type serverManager struct {
cfg *config.Config
log *zap.Logger
@@ -43,6 +56,7 @@ type serverManager struct {
mu sync.Mutex
srv *http.Server
redirectSrv *http.Server
ln net.Listener
pair *tlsPair
stopCh chan struct{}
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
return nil
}
oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair
oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair
if oldLn != nil {
_ = oldLn.Close() // 释放端口后再绑定新监听
}
m.srv, m.ln, m.pair = nil, nil, nil
m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil
firstErr := m.bind(pair)
if firstErr != nil {
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
}
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
m.drain(oldSrv)
m.drain(oldRedirect)
m.logServerReady()
m.maybeStartAutoReloadLocked()
return firstErr
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
close(m.stopCh)
}
m.mu.Lock()
defer m.mu.Unlock()
if m.srv == nil {
return nil
targets := []*http.Server{m.srv, m.redirectSrv}
m.mu.Unlock()
var firstErr error
for _, srv := range targets {
if srv == nil {
continue
}
if err := srv.Shutdown(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
return m.srv.Shutdown(ctx)
return firstErr
}
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
return pair.version == m.pair.version
}
// bind 创建并按需启用 TLS 的监听,异步开始服务。
// bind 创建监听,异步开始服务。
//
// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接
// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动
// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS
// server."。
func (m *serverManager) bind(pair *tlsPair) error {
ln, err := net.Listen("tcp", m.addr)
if err != nil {
return fmt.Errorf("listen %s: %w", m.addr, err)
}
srv := &http.Server{
Handler: m.handler,
if pair == nil {
srv := m.newHTTPServer(m.handler)
m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil
m.writeSchemeState(nil)
go m.serve(srv, ln)
return nil
}
split := newSplitListener(ln)
srv := m.newHTTPServer(m.handler)
tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
redirect := m.newHTTPServer(m.httpsRedirectHandler())
m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair
m.writeSchemeState(pair)
go m.serve(srv, tlsLn)
go m.serve(redirect, split.plainListener())
return nil
}
func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server {
return &http.Server{
Handler: handler,
ReadHeaderTimeout: 15 * time.Second,
}
if pair != nil {
ln = tls.NewListener(ln, &tls.Config{
Certificates: []tls.Certificate{pair.cert},
MinVersion: tls.VersionTLS12,
})
}
// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
// 同路径的 https://。
//
// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期
// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。
func (m *serverManager) httpsRedirectHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := redirectHostHeader(r)
if host == "" {
// 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "HTTPS is enabled; open https://<host>:%d instead.\n", m.cfg.App.Port)
return
}
status := http.StatusFound
if r.Method != http.MethodGet && r.Method != http.MethodHead {
status = http.StatusTemporaryRedirect // 保住方法与请求体
}
w.Header().Set("Location", httpsRedirectURL(host, r))
w.WriteHeader(status)
})
}
// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。
func httpsRedirectURL(host string, r *http.Request) string {
target := "https://" + host + r.URL.EscapedPath()
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
return target
}
// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射
// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符
// 带进 Location 造成任意跳转。
func redirectHostHeader(r *http.Request) string {
host := strings.TrimSpace(r.Host)
if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") {
return ""
}
return host
}
// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。
// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。
func (m *serverManager) writeSchemeState(pair *tlsPair) {
dataDir := strings.TrimSpace(m.cfg.App.DataDir)
if dataDir == "" {
return
}
scheme := "http"
if pair != nil {
scheme = "https"
}
path := filepath.Join(dataDir, httpSchemeFileName)
if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename.
m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err))
}
m.srv, m.ln, m.pair = srv, ln, pair
go m.serve(srv, ln)
return nil
}
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
@@ -271,7 +376,7 @@ func (m *serverManager) logServerReady() {
zap.String("listen", m.addr),
)
if m.pair != nil {
m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port",
m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS",
zap.String("addr", m.addr),
)
}
+139
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
DNSNames: []string{"localhost"},
// 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
if err != nil {
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
t.Fatal("version should change after files replaced")
}
}
// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致,
// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。
func TestWriteSchemeState(t *testing.T) {
m := newTestServerManager(t)
m.cfg.App.DataDir = t.TempDir()
path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName)
m.writeSchemeState(nil)
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("plain HTTP state: data=%q err=%v", data, err)
}
m.writeSchemeState(&tlsPair{})
if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("HTTPS state: data=%q err=%v", data, err)
}
// DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。
m.cfg.App.DataDir = ""
_ = os.Remove(path)
m.writeSchemeState(nil)
if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) {
t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err)
}
}
// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是
// 给用户看 "Client sent an HTTP request to an HTTPS server."。
func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) {
certPEM, keyPEM := makeTestPairPEM(t)
cfg := &config.Config{}
cfg.App.Port = 8080
cfg.App.DataDir = t.TempDir()
cfg.App.HTTPSEnabled = true
cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secure:"+r.URL.Path)
})
m := newServerManager(cfg, zap.NewNop(), handler)
m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口
if err := m.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = m.Shutdown(context.Background()) })
addr := m.ln.Addr().String()
// 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。
m.addr = addr
schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName)
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" {
t.Fatalf("scheme state = %q err=%v, want https", data, err)
}
plain := &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := plain.Get("http://" + addr + "/library?tab=1")
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusFound {
t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound)
}
if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want {
t.Fatalf("Location = %q, want %q", got, want)
}
postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatal(err)
}
defer func() { _ = postResp.Body.Close() }()
if postResp.StatusCode != http.StatusTemporaryRedirect {
t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect)
}
if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want {
t.Fatalf("POST Location = %q, want %q", got, want)
}
// 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(certPEM)) {
t.Fatal("append test certificate to pool failed")
}
secure := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12},
},
}
secureResp, err := secure.Get("https://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = secureResp.Body.Close() }()
body, err := io.ReadAll(secureResp.Body)
if err != nil {
t.Fatal(err)
}
if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" {
t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library")
}
// 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。
cfg.App.HTTPSEnabled = false
if err := m.Reload(); err != nil {
t.Fatal(err)
}
if m.redirectSrv != nil {
t.Fatal("redirect server should be released when HTTPS is off")
}
if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" {
t.Fatalf("scheme state = %q err=%v, want http", data, err)
}
plainResp, err := plain.Get("http://" + addr + "/library")
if err != nil {
t.Fatal(err)
}
defer func() { _ = plainResp.Body.Close() }()
plainBody, err := io.ReadAll(plainResp.Body)
if err != nil {
t.Fatal(err)
}
if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" {
t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody)
}
}
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#faf8f5" />
<meta name="description" content="MeBox — 个人家庭媒体中心" />
<link rel="icon" type="image/png" href="/brand/logo-64.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/brand/logo-192.png" />
<!-- 字体已自托管(见 src/index.css 的 @font-face),不再使用 Google Fonts 外链:
自托管环境常无法访问 Google,渲染阻塞外链会拖慢首屏。 -->
<title>MeBox</title>
<script type="module" crossorigin src="/assets/index-CcRvbC_F.js"></script>
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-B0Z9INg1.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-data-CD8zvwOM.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-react-Cz-iIQjW.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-motion-FEuwvkVZ.js">
<link rel="modulepreload" crossorigin href="/assets/auth-D3Hf5Dnr.js">
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-nIJoh1kU.js">
<link rel="stylesheet" crossorigin href="/assets/index-BPz3c5Oc.css">
</head>
<body class="font-body antialiased">
<div id="root"></div>
</body>
+4 -2
View File
@@ -62,7 +62,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -105,7 +105,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -115,7 +115,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -73,7 +73,9 @@ services:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+4 -2
View File
@@ -44,7 +44,7 @@ services:
# NAS 示例:source: /vol1/1000/Media
# Windows Docker Desktop 示例:source: D:/Media
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
- ./media: /media
- ./media:/media
environment:
TZ: Asia/Shanghai
@@ -87,7 +87,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+3 -1
View File
@@ -131,7 +131,9 @@ services:
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
healthcheck:
test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"]
# 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会
# 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。
test: ["CMD", "/usr/local/bin/mebox-healthcheck"]
interval: 30s
timeout: 10s
retries: 5
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# MeBox 容器健康检查。
#
# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再
# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里
# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远
# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生
# "first record does not look like a TLS handshake"。
#
# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见
# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议,
# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。
set -u
port="${MEBOX_APP_PORT:-8080}"
data_dir="${MEBOX_APP_DATA_DIR:-/data}"
scheme_file="$data_dir/.http_scheme"
scheme=""
if [ -r "$scheme_file" ]; then
scheme="$(tr -d '[:space:]' <"$scheme_file")"
fi
if [ "$scheme" = "https" ]; then
first="https"
second="http"
else
first="http"
second="https"
fi
for proto in "$first" "$second"; do
# --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。
if busybox wget -q --spider -T 3 --no-check-certificate \
"$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then
exit 0
fi
done
exit 1
+167
View File
@@ -0,0 +1,167 @@
# MeBox 阅读功能实施计划(legado 书源兼容)
> 分支:`feature/reading`
> 目标:在首页增加「影视 / 阅读」模式切换,阅读模式完整兼容阅读 3.0(legado)书源体系,覆盖 **文本(bookSourceType=0)、音频(=1)、漫画/图片(=2)** 三类源。
> 实现方式(用户明确要求):**样式与逻辑全部仿造 refgd/legado 本体**,不参考其他重实现项目;相当于用 Go + React 18 + TypeScript 5 重写该项目。
> 界面与交互的唯一规格:`docs/reader-ui-spec.md`(从 legado 源码逐屏调研产出)。
> 规则引擎的唯一语义基准:legado `app/src/main/java/io/legado/app/model/analyzeRule/` 源码,Go 侧逐方法移植对拍(源码克隆在 `C:\MyProject\_ref\legado`,仅作对照,不进入构建)。
## 1. 范围
**做:**
- 首页「影视/阅读」切换,阅读模式下有独立首页(书架/搜索/发现/最近阅读)
- legado 书源导入与管理(URL 导入、文本/JSON 粘贴导入、启停、分组、排序)
- 三类书源的完整链路:搜索 → 详情 → 目录 → 正文/播放列表/图片列表
- Go 侧规则引擎:CSS(jsoup 风格) / JSONPath / XPath / 正则 / 内嵌 JS 五种语法及其组合
- 文本阅读器、音频播放器、漫画阅读器三套前端 UI
- 追更、阅读进度同步(服务端存储,多端一致)、换源、替换净化规则
**不做(本期明确排除):**
- `webView` 类规则(需要无头浏览器,识别后标记该源为不兼容并提示)
- `webView` 真人校验类登录(loginUrl 走 WebView 人工过验证码的场景;
纯 JS / 表单类登录已支持,见 P4.5)
- RSS/订阅源、TTS 朗读、文件类型源(bookSourceType=3)
- 本地 TXT/EPUB 导入(列为后续可选)
## 2. 总体架构
沿用 MeBox 现有分层,全部新增代码集中在:
```
internal/
model/ # 新增 5 张表,注册进 AllModels() 自动迁移
repository/ # reader 相关 GORM 封装
service/reader/ # 规则引擎 + 书源业务(核心新增,预计占全部后端代码 70%)
handler/ # /api/reader/* 路由组
web/src/
pages/reader/ # 阅读端独立页面群(懒加载路由)
components/reader/
```
**基建复用**:`internal/helper/http.go`(浏览器 UA + 代理回退 HTTP 客户端)、`internal/service/runtime_cache.go`(影视侧 JSON/对象缓存)、`internal/service/image_proxy*`(封面与漫画图片代理)、`internal/handler/ws.go` 的 WSHub(搜索进度、追更任务推送,新增 `reader:*` topic)。
**数据流**:书源 JSON 存库 → 搜索/发现时按启用的源并发抓取(errgroup + 信号量限流,超时熔断)→ 结果聚合 → 前端。正文、播放地址、图片列表由服务端组装(含 `nextContentUrl` 翻页合并)后带 TTL 缓存下发;音频流与漫画图片按需经服务端代理补 UA/Referer 头。
## 3. 规则引擎(核心工作)
语义基准:gedoor/legado `app/src/main/java/io/legado/app/model/analyzeRule/` 下的 AnalyzeRule / AnalyzeByJSoup / AnalyzeByJSonPath / AnalyzeByRegex / AnalyzeUrl,逐项对拍测试。
### 3.1 组件与选型
| 组件 | 选型 | 说明 |
|---|---|---|
| HTML/CSS | `PuerkitoBio/goquery` | jsoup 等价物;jsoup 特有语法(class.x / id.x / tag.x / text.x / children / @text / @textNodes / @html / 属性选择)自己包一层 |
| XPath | `antchfx/htmlquery` | 对齐 JsoupXpath 语义 |
| JSONPath | `PaesslerAG/jsonpath`(备选 ohler55/ojg) | Jayway 语义 + 自实现 `\|\|`/`&&` 合并层,选型阶段需验证 |
| 正则 | Go regexp(RE2) | legado 部分源用 Java 正则语法,回退换 `dlclark/regexp2` |
| JS | `dop251/goja` | ES2017+,跑书源内嵌 JS |
| 字符集 | `golang.org/x/text` | GBK/GB18030 解码 |
### 3.2 引擎能力清单
- 规则模式识别:`@css:` / `$.`(JSONPath) / `@XPath:`或`//` / `<js>`与`@js:` / `##` 正则替换段
- 列表组合符 `&&` / `||` / `%%`,变量存取 `@put:{}` / `@get:{}`,内嵌 JS `{{ }}`
- jsoup 分析器、JSONPath 分析器、XPath 分析器、正则分析器,以及混合规则的链式解析(AnalyzeRule 的分段执行语义)
- AnalyzeUrl:`{{key}}`/`{{page}}` 变量、`<js>` 生成 URL、URL 后 `,{...}` 选项(method/body/charset/headers/retry/timeout/type/proxy/js/webView)
- JS 沙箱:goja 运行时 + 执行超时中断 + 禁止直接 IO;上下文注入 `java`、`source`、`book`、`baseUrl`、`result` 等对象
- `java.*` 桥接函数(按书源实际使用频率分批实现):
- 网络:ajax / ajaxAll / connect / get / post / head
- 编解码:base64Decode/Encode(含 URL-safe)、hexDecode、encodeURI/decodeURI、htmlDecode
- 加解密:md5(16/32)、sha1/sha256、AES/DES/3DES/RSA(CBC/ECB + 常见 padding/key 语义,legado 源里最常见的坑)
- 字符串与时间:replaceAll/substring/正则族、timeFormat 等
- 规则回调:`java.getString/getElement` 等,桥回 Go 规则引擎(JS 与规则互相嵌套的关键)
### 3.3 兼容策略
- 引擎按能力分层实现,每个能力配真实书源样本的单测(fixtures 放 `internal/service/reader/testdata/`)
- 提供 CLI 冒烟工具(如 `cmd/reader-smoke`):对批量导入的公开书源集跑 搜索/详情/目录/正文 全链路,输出成功率报告,作为每个阶段验收依据
- 含 `webView` 选项的源直接判定不兼容并在书源管理页标注
## 4. 数据模型(新增表)
| 表 | 关键字段 |
|---|---|
| book_sources | name, group, type(0/1/2), source_url, json(原文), enabled, custom_order, last_check_at, comment |
| books(书架) | source_url, book_url, name, author, cover_url, intro, kind, type(文本/音频/图片), latest_chapter, total_chapters, last_read_chapter_index, last_read_at |
| book_chapters | book_id, index, title, url, is_volume, update_time |
| read_progress | book_id(唯一), chapter_index, position(滚动/秒/图片序), updated_at |
| replace_rules | name, find, replace, scope, is_regex, enabled, order |
阅读器显示设置(主题/字体/翻页方式)存前端 localStorage,不上服务端。
## 5. API 设计(/api/reader/*)
- 书源:`GET/POST/DELETE /sources`、`POST /sources/import`(URL 或 JSON/base64 文本,自动识别格式与类型)、`PATCH /sources/:id`(启停/排序)
- 搜索:`POST /search {keyword}` → 后台聚合任务,结果经 WS `reader:search` 增量推送;结果可一键加入书架
- 发现:`GET /explore?source=&group=`(解析 exploreUrl 的 `分组名::url` 结构)
- 书架:`GET/POST/DELETE /books`、`GET /books/:id/info`、`GET /books/:id/toc`、`POST /books/:id/refresh`(追更)
- 本地书籍:`POST /local/books`(multipart 上传 TXT/EPUB,导入即入书架)
- 内容:`GET /books/:id/chapters/:idx/content` —— 按书籍类型返回:
- 文本:`{type:"text", content:"..."}`(服务端已合并 nextContentUrl 翻页、已应用替换规则)
- 音频:`{type:"audio", tracks:[{url,title}]}`(含代理路径与所需请求头)
- 图片:`{type:"image", images:[{url, style}]}`(同样经代理)
- 进度:`PUT /books/:id/progress`
- 替换规则:`/replace-rules` CRUD
- 调试:`POST /debug {source_id, rule, url}`(书源调试器后端)
- 图片/流代理:复用现有 image_proxy / stream_proxy 模式,按源配置注入 UA/Referer
## 6. 前端设计
- **首页切换**:`HomePage.tsx` 顶部加分段控件(仿 `LibraryTagBar` tab 模式),「阅读」切到阅读首页;选择持久化(zustand + localStorage)
- **阅读首页**:继续阅读横排 + 书架封面网格 + 搜索入口 + 追更提示
- **页面群**(懒加载,仿 `appRoutes.tsx`):`/reader`(首页)、`/reader/search`(多源并发搜索 + 实时进度)、`/reader/explore`、`/reader/book/:id`(详情 + 目录 + 换源)、`/reader/sources`(书源管理 + 调试器)
- **三套阅读器**:
- 文本:滚动 + 分页双模式(CSS 分栏测量分页)、主题(含夜间)、字体/行距/边距、点击翻页区、章节预加载、进度上报
- 音频:`hls.js`(已是依赖)+ `<audio>` 兜底,播放列表、倍速、记忆进度、锁屏/息屏策略
- 漫画:上下滚动 / 左右翻页双模式、相邻图片预加载、`imageStyle` 支持
## 7. 阶段划分
| 阶段 | 内容 | 交付物 |
|---|---|---|
| P0 引擎地基 ✅ | 规则引擎核心(四分析器 + 规则拆分/组合/变量)+ AnalyzeUrl v1(GET/POST/charset/headers/变量/页码模式)+ 表结构 + 书源导入/管理 API + 搜索/详情/目录/正文/书架/进度/调试 API | 已完成:`internal/service/reader/rule/`(规则引擎,~2800 行,对齐 AnalyzeRule/AnalyzeByJSoup/AnalyzeByJSonPath/AnalyzeByXPath/AnalyzeByRegex/AnalyzeUrl/RuleAnalyzer)+ 服务层 + `/api/reader/*` 路由 + 单测/端到端测试全绿 |
| P1 文本源全链路 + 首页切换 | 搜索聚合(WS 进度)/详情/目录/正文(nextContentUrl 合并、缓存)+ 前端首页切换、书架、搜索、详情、文本阅读器 v1(阅读器样式仿 legado:9 宫格点击、主题、翻页动画) | 用纯规则型文本源完成「搜书→加入→阅读」全流程 |
| P2 JS 与兼容率爬坡 ✅ | goja 接入 + `java.*` 桥(网络/编解码/摘要/对称加密全家桶/规则回调,函数名对齐 JsExtensions)+ URL 规则 JS(analyzeJs/{{}}/js/bodyJs)+ cookie jar + 用户替换净化规则(含正则超时保护)+ 替换净化页 + 结构化冒烟链路(SmokeChain)+ `cmd/reader-smoke` 冒烟 CLI | JS 源可用;冒烟 CLI 跑公开书源集出各阶段通过率报告 |
| P3 音频源 ✅ | 正文按音频类型返回播放列表(绝对化)+ `/api/reader/media` 签名媒体代理(HMAC 防滥用、Range 透传支持拖动、m3u8 分片/密钥地址重写)+ 听书面板(hls.js 播 m3u8、直链 `<audio>`、上一章/-15s/播放暂停/+15s/下一章、章节选择抽屉、定时关闭 0–180 分钟、倍速 0.5–3.0、跳过片头片尾按书持久化 `PUT /api/reader/books/:id/audio-config`、进度按秒记忆、播完/片尾到点自动下一章) | 音频源可听 |
| P4 漫画/图片源 ✅ | 图片列表绝对化 + 经签名代理(带书源 Referer 防盗链头)+ 漫画阅读器(上下滚动/左右单页双模式、图片懒加载与加载失败占位、点击分区翻页/呼菜单、菜单进度条按图片序号、进度按图片序号记忆、下一章预取)+ imageStyle 透传 | 漫画源可看 |
| P4.5 书源登录 ✅ | `jsLib` 一次装载(对应 legado SharedJsScope)+ `source.*` 会话方法(getVariable/setVariable/getLoginInfo/putLoginInfo/getLoginHeader/putLoginHeader/get/put)+ `cookie.*`(getCookie/setCookie/replaceCookie/removeCookie/getKey,按 eTLD+1 隔离)+ 请求自动携带 Cookie 与 loginHeader + `loginUrl`/`loginUi`(解析表单 → 按钮 action 拼在 loginUrl 后执行 → result 为表单值)+ `loginCheckJs`(会话失效自动重登/重取)+ 源变量落库 + 登录信息/Cookie 加密存储 + 服务端 toast/startBrowser 回传前端 + 登录面板与变量编辑器 | 登录类书源可登录、可留存登录态 |
| P4.6 本地书籍 ✅ | `POST /api/reader/local/books` 上传 TXT / EPUB(上限 64MB)→ 落盘 `data/reader/local/<bookID>.<txt\|epub>` + 解析目录入 `reader_chapters` + 落库为 `origin=""`、`is_local=true` 的书架条目;TXT 自动识别 BOM/UTF-8/GBK/Big5/UTF-16 并按 legado 默认 TXT 目录规则切章(RE2 无 lookbehind,改行首锚定 + 句子启发式过滤),章定位信息存字节区间 `start:end`,读章只读该区间;EPUB 走 container.xml → OPF spine,标题优先取 NCX/NAV,正文去标签与实体;移出书架同步删落盘文件;同名重复导入覆盖更新并尽量保留进度 | 本地书可上传、可读、可删 |
| P4.10 书源按用户独立 ✅ | 书源从全局唯一改为按用户独立:`ReaderBookSource` 增加 `user_id`,唯一键改为 `(user_id, source_url)`;导入/列表/启停/删除/搜索全部按当前用户限定;老库迁移自动删旧唯一索引、按「谁书架上有这本书」回填归属并给多引用用户复制副本、清理软删历史行;删除改为物理删除(软删会占住唯一键导致「删掉再导入」失败);正文缓存键随之改为「书源行 ID + 书本地址」,不同用户改过规则的同名书源不再共享缓存 | 用户的启停/规则/变量/删除互不影响 |
| P4.7 正文持久缓存 ✅ | 服务端正文持久缓存(`reader_content_cache.go`:书源侧产物 + 索引落库 + 内容落盘 `cache_dir/reader-content/`、TTL/容量 LRU、目录刷新 remap、换源/移出书架清理、读穿透 singleflight)+ 批量取正文接口 `POST /api/reader/books/:id/content-batch`(对应 CacheBook 协议)+ 前端窗口预取(默认 3 章) | 慢源连续翻章不再每章等待;重复阅读零网络 |
| P4.8 图片/字体兼容 ✅ | `coverDecodeJs`(封面解密代理,含搜索结果未入库场景)/ `ruleContent.imageDecode`(正文图片解密)+ 图片 URL 尾部 `,{headers}` options 逐图应用(options 优先于书源 header 与 Cookie)+ `java.queryTTF` / `queryBase64TTF` / `replaceFont`(sfnt cmap 0/4/6、loca 短/长、glyf 简单+复合字形) | 加密图与字体混淆正文可读 |
| P4.9 规则稳定性 ✅ | `@put` 回写书源变量(修复跨请求丢失);`book.getVariable/putVariable` 真正注入书籍上下文并落库(对应 Book.upVariable);全部书源 `regexp2` 统一 3s 匹配超时;解压输出 32MB 上限;非 UTF-8 TXT 托管副本按 UTF-8 落盘(外部引用读时解码);换源单事务;JS `cache` 按书源命名空间隔离并落盘;HLS 双端识别(服务端 `#EXTM3U` 嗅探 + `hls` 标志下发) | 与阅读 App 行为对齐,坏源不再拖死服务 |
| P5 体验完善 | 换源(ChangeBookSourceDialog 四档排序)、追更(定时刷新目录 + 缓存清理)、发现页(exploreUrl 标签条)、阅读器高级设置(页眉页脚提示、点击区域自定义)、书源编辑器六 Tab、备份导出 | 完整体验 |
### 实施记录:书源会话状态仍是跨用户共享(下一步)
书源本身已按用户独立,但 `ReaderSourceState`(Cookie / 登录信息 / loginHeader / 源变量)
仍以 `source_url` 为唯一键、跨用户共享一份。两个用户导入同一书源时:
- 一方登录后,另一方复用同一份 Cookie;一方的登出会清掉另一方的登录态。
彻底隔离需要给状态表也加 `user_id`(或按书源行 ID 组织),涉及登录面板与变量读写
的一批调用点,留作单独一轮处理。
### 实施记录:关于「宽松 @put 触发 panic」的复核
前期评估曾报告 `sourcerule.go` 的宽松 `@put` 回退正则含 RE2 不支持的反向引用、可能 panic。
逐行复核后该结论**不成立**:`splitPutRule` 的下标全部由固定捕获分组数保证。
真实缺陷是另外两个并已修复:
- `AnalyzeRule.Put` 没有 `sourcePutter` 分支,`@put` 只写进一次性的 `bookVars`,跨请求丢失;
- `applyBookContext` 的三处调用点都在传 `book=nil`,`book.getVariable/putVariable` 与 `book.name` 长期是死代码。
P0–P2 是主体(约全部工作量 60–70%),P3/P4 相对独立可并行。
## 8. 风险与对策
- **书源质量参差**:单源解析全程超时与重试上限;正文翻页循环设页数上限防死循环
- **goja 性能**:复杂 JS 源解析慢 → 正文/目录强缓存、单源并发限 1、全局执行预算
- **Java 正则/JSoup 细节差异**:以对拍测试驱动修正,regex 备选 regexp2
- **热源防盗链**:图片与音频一律走带请求头的代理
- **合规**:不内置任何书源,全部由用户自行导入,管理页附免责说明
## 9. 参考
- **唯一语义与样式基准**:https://github.com/refgd/legado (规则引擎 analyzeRule 源码 + UI 布局/交互,见 docs/reader-ui-spec.md)
- 书源规则教程:https://mgz0227.github.io/The-tutorial-of-Legado/
+260
View File
@@ -0,0 +1,260 @@
# legado UI/交互仿制规格(React 18 + Tailwind Web 版实现依据)
> 来源:对 `refgd/legado`(克隆于 `C:\MyProject\_ref\legado`)源码的调研。
> 路径缩写:`<src>` = `app/src/main/java/io/legado/app/`,布局在 `app/src/main/res/layout/`。
## 1. 首页/书架
文件:`<src>ui/main/bookshelf/`(BookshelfFragment、BooksAdapterGrid/List)、`fragment_bookshelf1/2.xml`、`item_bookshelf_grid*.xml`、`item_bookshelf_list*.xml`。主界面底部 BottomNavigation(书架/发现/我的)+ 右上悬浮搜索按钮。
**布局结构(从上到下)**
- style1(标签页风格):标题行(书架标题 + 下拉箭头,点开分组切换菜单)→ 搜索按钮 + 更多按钮 → 一级分组 tab 条(tab 长按可删分组)→ 次级分组扩展标签 → ViewPager(每 tab 一个书籍列表/网格)。
- style2(单 RecyclerView 混排):title_bar → SwipeRefresh → 书籍列表(根分组时「分组网格项 + 书籍项」混排,进入分组只显示书籍)→ 空态文案。
- 网格项:封面 + 本地角标 + 未读数角标 + 加载中 + 书名 + 长按遮罩。
- 列表项:封面 + 本地图标 + 有更新标记 + 未读数 + 书名 + 作者 + 最后更新时间 + 当前读到(章节名)+ 最新章节(章节名)+ 长按遮罩。
**交互逻辑**
- 内置分组 id:全部(IdRoot=-100)、本地(IdLocal=-2)、未分组(IdUngrouped=-4),其余用户分组。
- 单击书 → 直接进入阅读;单击分组 → 进入该分组。
- 长按书 → 跳转书籍详情页;长按分组 → 重命名/删除对话框;style1 长按 tab 删除分组。
- 右上菜单:搜索、Wi-Fi 传书、刷新目录、书架布局切换、分组管理、导出/导入书架、下载离线、本地导入、网址添加、日志。
**MeBox 的「影视 / 阅读」首页模式**(`stores/readerSettings.homeMode` + 顶栏 `LayoutReaderModeToggle`)
- 切换入口在**顶栏**,位于搜索框与账号菜单之间,**只显示图标不显示文字**:影视模式显示场记板图标,阅读模式显示书图标(并带品牌色高亮),点一下切到另一个模块、图标随之变化。悬停提示写明「当前是 X 模式,点击切换到 Y」,不在首页时点击会先跳回 `/`。
- 两种模式共用一个首页路由 `/`:影视模式渲染媒体首页,阅读模式渲染书架(`ReaderHomeContent embedded`)。首页内容区不再放分段式「影视 / 阅读」开关(原 `ReaderModeSwitch` 仅留给独立布局的 `/reader` 首页兜底)。
- 切到阅读模式时,顶部搜索框换成**书搜索**(`LayoutHeaderBookSearch`,占位「搜索书籍…」):聚焦即下拉书架(本地即时过滤书名/作者,点条目进阅读器),回车优先打开书架首条;下拉底部固定一行「在书源中搜索「xxx」」,带 `?key=` 跳到 `/reader/search` 由多源聚合搜索页自动开搜。账号/主题菜单照常显示。
- 阅读模式下移动端底部导航(首页/媒体库/收藏/列表/更多)隐藏,避免影视导航混进书架。
- `/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 的差距):书籍二级分组网格(进入分组后的封面网格)、导出/导入书架、离线下载管理界面(服务端批量缓存接口与窗口预取已可用,见下)。
- 章节预取:阅读页对当前章之后默认 3 章走 `POST /reader/books/:id/content-batch` 批量抓取(服务端并发 + 持久缓存),翻到后续章直接命中;单章串行预取在高延迟书源上赶不上连续翻章。
- 书源按用户独立:书源管理(列表/导入/启停/删除)与搜索都只作用于当前用户自己的书源。同一份书源不同用户可以各导入一份,各自启停、排序、改规则与变量,互不可见;Web 端无需额外交互,接口按登录用户自动隔离。
**书架分组(`/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)。
**布局结构(单 FrameLayout 栈)**:read_view 正文 → 文字选择光标 → read_menu 主菜单 → search_menu 全文搜索 → 朗读回原文浮条。
**正文渲染**
- ReadView 内含 3 个 PageView(prev/cur/next 缓存),ContentTextView 纯 Canvas 自绘:按字号/行距/段距/缩进/两端对齐排版,Web 版用 CSS multi-column 或 JS 分页等效实现。
- 翻页动画(0–5):0 覆盖、1 平移、2 仿真、3 上下滚动、4 无、5 平移覆盖;速度可调(默认 300ms)。
- 滚动模式支持背景跟随、自动翻页(定时滚动/定时翻页)。
- 图片/漫画书切换独立配置集 + MangaMenu:顶栏 + 底部胶囊(上一页/页码进度条/下一页),支持横滚、缩放。
**点击区域(9 宫格,边缘留 pageTouchClick px)**
- 区块:左上/中上/右上/左中/中/右中/左下/中下/右下。
- 默认:左列与上中=上一页;右列与下中=下一页;正中=呼出菜单。
- 全部可配动作:0 呼出菜单、1 下一页、2 上一页、3 下一章、4 上一章、7 书签、10 目录、11 搜索、12 同步进度等。
**主菜单 ReadMenu(自上而下)**
- 半透明遮罩(点击收起)。
- 顶部 title_bar:返回 + 书名(点击进详情)+ 章节名/章节链接(点击打开原页面)+ 书源按钮(弹菜单:编辑书源/禁用书源等)。
- 底部 bottom_menu(圆角面板):上一章 | 进度条 | 下一章 → 亮度条 + 自动亮度 → 快捷按钮行(搜索、自动翻页、替换规则、夜间/日间切换)→ 动作面板行(目录、朗读、界面、设置)。
- 菜单配色可跟随页面背景或用主题底色,透明度 35–100。
**设置面板**
- 「界面」三 Tab:文本(字体文件、字重、字号 5–50、字距、行距 0–20、段距、缩进、下划线、阴影);样式(白天/夜间/E-Ink 三态主题:文字色、背景色/图、强调色、菜单底色、菜单透明度、背景透明度,可恢复/管理/分享);页面(6 种翻页动画、状态栏深浅、滚动背景跟随、四边距、页眉页脚提示、简繁转换)。
- 「设置」:屏幕方向、保持亮屏、隐藏状态栏、两端/底部对齐、竖排、双页、进度条行为(按页/按章)、音量键/滚轮翻页、点击区域配置、动画速度、自动换源等。
- 页眉页脚提示项:无/章节名/时间/电量/电量百分比/页码/总进度/页码+总页/时间+电量/书名;页眉默认 左=时间/右=电量,页脚默认 左=章节名/右=页码/总页。
**内置主题与配色(assets/defaultData/readConfig.json,6 套 + 自定义)**
| 名称 | 背景(日/夜) | 文字(日/夜) | 强调(日/夜) | 字号 |
|---|---|---|---|---|
| 微信读书 | #FFC0EDC6 / #000000 | #FF0B0B0B / #ADADAD | #E53935 / #FE4D55 | 24 |
| 预设1 | #FFFFFF / #000000 | #000000 / #FFFFFF | #E53935 / #FE4D55 | 20 |
| 预设2(羊皮纸) | #DDC090 / #3C3F43 | #3E3422 / #DCDFE1 | #834E00 / #FE4D55 | 20 |
| 预设3(护眼绿) | #C2D8AA / #3C3F43 | #596C44 / #88C16F | #E53935 / #FE4D55 | 20 |
| 预设4(粉紫) | #DBB8E2 / #3C3F43 | #68516C / #F6AEAE | #801314 / #90BFF5 | 20 |
| 预设5(淡蓝) | #ABCEE0 / #3C3F43 | #3D4C54 / #90BFF5 | #E53935 / #FE4D55 | 20 |
默认排版:textSize 20、letterSpacing 0.1、lineSpacingExtra 12、paragraphSpacing 2、缩进「  」、padding 上下6/左右16、页脚线 true。未选样式时默认:bg #EEEEEE / 夜 #000000 / E-Ink #FFFFFF,文字 #3E3D3B / 夜 #ADADAD,强调 #E53935 / 夜 #FE4D55。
**MeBox 文本阅读器已实现的排版/菜单细节**(`ReaderViewPage.tsx`)
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 点击分区:翻页模式渲染一层 `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+滚轮` 保留浏览器缩放。
- **浏览器全屏(沉浸式)**:阅读页本身已经是 `fixed inset-0` 的独立布局(`/reader/*` 不套影视 Layout),所以屏幕上的留白只剩浏览器自带的地址栏/工具栏。底部动作行加「全屏 / 还原」,对 `readerRef` 调标准 Fullscreen API(`requestFullscreen` / `exitFullscreen`),进全屏前先收起菜单。按钮只在 `document.fullscreenEnabled === true` 时出现——**iOS 上的 Safari 至今不支持元素全屏**(只有 `<video>` 能全屏),所以那里不显示按钮,而不是显示一个点了没反应的死按钮;iPhone 想彻底去掉浏览器界面只能「添加到主屏幕」。离开阅读页时卸载 effect 主动 `exitFullscreen`,否则返回书架后浏览器仍停在全屏、整站都被罩住。全屏状态由 `fullscreenchange` 事件回写,不做乐观更新(用户按 Esc / 系统手势退出时也要跟着变)。
- **手机端双击中间区域进出全屏**(`hooks/useDoubleTap.ts` + `hooks/useIsTouchDevice.ts`):中间区域单击本来就是呼出菜单,现在双击再切一次全屏,两个动作在同一块屏幕上,得先等一小会儿看有没有第二下。`useDoubleTap` 把这段窗口做成纯函数 `decideDoubleTap`(第一下进 `tap` 窗口等第二下;第二下到达取消单击、改判双击),**双击回调在第二次点击的事件里同步执行**——浏览器只认用户手势上下文里的 `requestFullscreen`,挪进定时器再调会被直接拒绝。只在「触摸设备(`(hover: none)`)+ 支持元素全屏」时启用:桌面端根本不装这套,单击立刻生效,点中间呼出菜单的手感与以前完全一致;iOS 也不装(它不支持元素全屏)。三条中间点击路径(翻页模式的三分区覆盖层、文本滚动模式的 `handleZoneTap`、漫画滚动模式的 `onZone`)统一走 `centerTap()`,左右分区点击会 `cancel()` 掉待判定的单击,免得点完翻页又弹出菜单。菜单打开时整屏遮罩先吃掉那次点击,遮罩在中间区域关闭菜单的同时 `arm()` 一个双击窗口,「双击中间切全屏」在菜单开着时也成立。手机端再给阅读根节点挂 `touch-action: manipulation`,关掉浏览器自带的双击缩放(纵向滚动与双指缩放不受影响),避免同一手势被浏览器抢着缩一次。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)**
- 入口:书架页右上「本地导入」按钮(书架为空时另有「上传本地书籍」),支持 TXT / EPUB,单文件上限 64MB,上传后自动入库并直接进入阅读页。
- 书架卡片:本地书打「本地」角标,右上角有删除按钮(二次确认),删除会连服务器上的文件一起清掉。
- 存储:正文落盘 `data/reader/local/<bookID>.<txt|epub>`;目录信息与网络书共用 `reader_chapters`,用 `Tag` 记定位:TXT 存 UTF-8 规范化后文件内的字节区间 `start:end`,EPUB 存 zip 内的 XHTML 条目路径。读章只取所需区间/条目,不整本载入内存。
- TXT:自动识别 BOM(UTF-8/UTF-16) 与 UTF-8 / GBK / Big5,统一转 UTF-8 落盘;目录按 legado 默认 TXT 规则切章(`第X章/节/卷/集/部/篇`、序章、楔子、番外等),并对「第一章的正文内容」这类正文行做启发式过滤,切不出章名时整本当一章「全文」,章前内容(书名/简介)并入第一章。
- EPUB:`META-INF/container.xml` → OPF → `spine` 顺序出章。目录标题优先取 NCX / EPUB3 NAV,**逐 token 走并用栈收任意层级的 navPoint**——Epubor 等工具导出的 EPUB 常漏 `</navPoint>`,标题会整棵嵌进上一个节点,按固定层级解会丢掉一大半标题;取不到时依次退回正文首行 → 封面页(文件名含 cover 且该页只有图)标「封面」→ `<title>`(过滤 Cover/Table of Contents 这类无信息量的)→ `第 N 章`。
- EPUB 图片:正文里的 `<img>` 在转纯文本时就地换成 `[img]<zip 条目>` 标记行(相对路径按该 XHTML 所在目录解析),下发前把标记换成签名地址 `/api/reader/local/asset?b=&p=&s=`(HMAC,`<img>` 带不了 JWT),前端把 `[img]` 开头的行渲染成居中图片(`max-width:100%` + `max-height:70vh`,保证不撑破分栏)。网络书正文不受影响。
- 进度:与网络书同一套 `durChapter*` 字段,跨端一致;同名文件重复上传按覆盖更新处理(章数不变则保留进度)。
**音频书播放条(AudioPlayActivity + AudioPlayService,即「听书」)**
- 背景与封面:书籍封面强模糊(blur 32px)铺满做底,叠一层很淡的主题底色(opacity 0.3)保住日/夜对比度;正中圆形显示封面原图(对应 legado `upCover` 的 ivBg 模糊图 + ivCover 圆图)。封面缺失或加载失败时退回主题色圆点,不留破图。
- transport 行:上一章 | -15s | 播放暂停 | +15s | 下一章(SEEK_STEP=15s,进度按秒)。
- 动作行(常驻底部,抽屉打开时仍可点):章节(目录选择,见下)、定时关闭、倍速、片头片尾。
- 章节选择:底部抽屉列全部章节,定位到当前章、当前章高亮、点章即跳;卷名行不可点。
- 定时关闭: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-片尾秒数即等同播完,有下一章则自动续播,末章则停在片尾处。**单位是秒,不是章数。**
- 播放进度按秒记忆(节流 5s 上报),跨端一致;播完自动下一章。
- legado 的「播放模式」(顺序/单章循环/随机/列表循环)与「音频服务唤醒锁」是客户端能力,Web 端未实现。
## 3. 搜索
文件:`<src>ui/book/search/` + `<src>model/webBook/SearchModel.kt`。
- 布局:顶部 SearchView + 转圈进度条 → 结果流 → 书架命中提示卡 → 搜索历史 chips + 清空 → 右下 开始/停止 悬浮按钮。
- 结果项:封面、在书架角标、书源数角标(可换源数)、书名、作者、分类标签、最新章节、简介。
- 多源并发:所有启用书源(按搜索范围过滤),固定线程池并发(全局设置 threadCount),单源 30s 超时。
- 实时结果流:每源返回即刷新列表;结束后 onSearchFinish(isEmpty, hasMore)(任一源有结果即可翻页,searchPage++)。
- 聚合去重 mergeItems 四档:书名或作者等于关键词 > kind 含关键词 > 书名/作者含关键词 > 其他;同名同作者合并为一项并 addOrigin(角标显示可换源数);档内按书源数降序。
- 顶部菜单:精准搜索开关、搜索范围(分组/全选)、书源管理、日志。点结果 → 换源对话框/直接打开。
## 4. 发现
`<src>ui/main/explore/` + `ui/book/explore/`。布局:标题栏 → SwipeRefresh → 源选择行(当前源名 + 下拉、源内搜索、标签筛选、更多)→ 二级筛选条 → 分类标签条(横滑 chip,可展开)→ 书籍网格(封面+书名)→ 兜底列表。数据来自启用书源的 exploreUrl + ruleExplore;切源刷新标签;点标签拼 URL 加载、分页加载更多。
## 5. 书籍详情
`<src>ui/book/info/BookInfoActivity.kt`。布局:模糊封面背景 → 标题栏(返回/刷新/菜单)→ 下拉刷新 → 滚动区:封面 + 书名 + 作者 + 最新章节(点击刷新目录)+ 阅读时长 + 分类标签 + 分组行 + 书源行(换源)+ 目录入口行 → 双 Tab(简介 / 目录预览,可全屏、点章节跳读)→ 底部动作区:tv_shelf 加入/移出书架(描边按钮)+ tv_read 开始阅读/继续阅读(实心主色按钮)。菜单含置顶、去书源网页、登录、删除(勾选删缓存)、分享。
## 6. 目录页
`<src>ui/book/toc/`。布局:标题栏(返回 + 书名 + SearchView 搜章节)→ Tab(章节/书签)→ 章节 RecyclerView + 底部信息条(当前位置 + 跳顶/跳底)。菜单:倒序开关、使用净化替换、加载字数、长章分卷合并、TXT 目录规则。倒序即列表反转;卷名行可折叠;已读章节变色;点击章节回传 index 跳读。
## 7. 书源管理
- **主列表**:标题栏(SearchView 过滤 + 菜单)→ RecyclerView(item:域名、复选、启停 Switch、编辑、更多菜单、发现、快速调试、响应耗时条)→ 多选操作栏。菜单:排序(手动/自动/名称/URL/更新时间/响应时间/启用)、分组筛选(启用/禁用/需登录/无分组/启用发现/禁用发现)、添加书源(本地/网络/二维码)。
- **编辑**:标题栏(保存/调试入口)→ 类型下拉 + 六个开关(启用/启用发现/CookieJar/段评/事件监听/自定义按钮)→ 六个 Tab:基本/搜索/发现/详情/目录/正文,每 Tab 为「标签 + 输入框 + 帮助弹层」表单。
- **调试**:顶部 SearchView 输入关键词 → 串行执行 搜索→详情→目录→正文,日志逐条流式显示;可查看各阶段原始 HTML、切换探索分类调试。
## 8. 替换规则
- 列表页:标题栏(搜索 + 菜单:分组管理、启停筛选、添加、导入)→ RecyclerView(名称、分组、内容摘要、启停 Switch)→ 多选操作栏。
- 编辑页字段:规则名 → 分组 → 替换规则(+ 正则开关 + 帮助)→ 替换为 → 作用于标题 / 作用于正文 → 作用范围(书名) → 排除范围 → 超时毫秒(order 隐含)。
## 9. 数据实体字段全集
**BookSource.kt**(rule* 为内嵌对象)
| 字段 | 类型 | 默认值 |
|---|---|---|
| bookSourceUrl | String | "" |
| bookSourceName | String | "" |
| bookSourceGroup | String? | null |
| bookSourceType | Int (0文本/1音频/2图片/3文件/4视频) | 0 |
| bookUrlPattern | String? | null |
| customOrder | Int | 0 |
| enabled | Boolean | true |
| jsLib | String? | null |
| enabledExplore | Boolean | true |
| enabledCookieJar | Boolean? | true |
| concurrentRate | String? | null |
| header | String? | null |
| loginUrl / loginUi / loginCheckJs / coverDecodeJs | String? | null |
| bookSourceComment | String? | null |
| variableComment | String? | null |
| lastUpdateTime | Long | 0 |
| respondTime | Long | 180000 |
| weight | Int | 0 |
| exploreUrl / exploreScreen | String? | null |
| ruleExplore / ruleSearch / ruleBookInfo / ruleToc / ruleContent / ruleReview | 对象 | null |
| eventListener / customButton | Boolean | false |
**Book.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| bookUrl (PK) / tocUrl | String | "" / "" |
| origin / originName | String | localTag / "" |
| name / author | String | "" / "" |
| kind / customTag / coverUrl / customCoverUrl / intro / customIntro / charset | String? | null |
| type | Int (BookType) | text |
| group | Long | 0 |
| latestChapterTitle | String? | null |
| latestChapterTime / lastCheckTime | Long | now |
| lastCheckCount / totalChapterNum | Int | 0 |
| durChapterTitle | String? | null |
| durChapterIndex / durVolumeIndex / chapterInVolumeIndex / durChapterPos | Int | 0 |
| durChapterTime | Long | now |
| wordCount | String? | null |
| canUpdate | Boolean | true |
| order / originOrder | Int | 0 |
| variable | String? | null |
| readConfig(ReadConfig 内嵌) | reverseToc、pageAnim、reSegment、imageStyle、useReplaceRule、delTag、ttsEngine、splitLongChapter=true、readSimulating、startDate、startChapter、dailyChapters=3、openCredits=0、closeCredits=0、playMode=0、playSpeed=1.0、manga* 系列、mangaPageAnim | |
**BookChapter.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| url / title | String | "" / "" |
| isVolume | Boolean | false |
| baseUrl / bookUrl | String | "" / "" |
| index | Int | 0 |
| isVip / isPay | Boolean | false |
| resourceUrl / tag / wordCount | String? | null |
| start / end | Long? | null |
| startFragmentId / endFragmentId / variable / imgUrl | String? | null |
**ReplaceRule.kt**
| 字段 | 类型 | 默认值 |
|---|---|---|
| id | Long | now |
| name | String | "" |
| group | String? | null |
| pattern | String | "" |
| replacement | String | "" |
| scope | String? | null |
| scopeTitle | Boolean | false |
| scopeContent | Boolean | true |
| excludeScope | String? | null |
| isEnabled | Boolean | true |
| isRegex | Boolean | true |
| timeoutMillisecond | Long | 3000 |
| order | Int | Int.MIN_VALUE |
## 10. 主题与视觉
- 全局:主背景 `#F7F7FA`、主文字 `#DE000000`、品牌色 `#FFF6FBF8`(浅绿白调)。
- 界面主题由 ThemeConfig(themes.json)控制 6 套;阅读菜单/底栏用 bottomBackground(跟随所选界面主题),沉浸模式(readBarStyleFollowPage)直接取页面背景/文字色。
- 强调色 accentColor 用于选中态、光标、Tab 选中;E-Ink 模式菜单改描边。
- 视觉风格:大圆角面板、1dp 描边、玻璃拟态半透明条、图标+文字标签的动作面板。
## Web 版实现要点
- 正文分页仿 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/字段一一对应。
+17 -5
View File
@@ -1,6 +1,6 @@
module github.com/truewhile/MeBox
go 1.25.0
go 1.26.0
require (
fyne.io/systray v1.12.2
@@ -19,9 +19,9 @@ require (
github.com/stretchr/testify v1.11.1
github.com/ulikunitz/xz v0.5.15
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.49.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.45.0
golang.org/x/sync v0.22.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.47.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
@@ -30,6 +30,12 @@ require (
)
require (
github.com/PaesslerAG/gval v1.0.0 // indirect
github.com/PaesslerAG/jsonpath v0.1.1 // indirect
github.com/PuerkitoBio/goquery v1.13.0 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/antchfx/htmlquery v1.3.6 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -38,6 +44,9 @@ require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
@@ -47,9 +56,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -90,8 +102,8 @@ require (
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.70.0 // indirect
+97
View File
@@ -1,7 +1,20 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/PaesslerAG/gval v1.0.0 h1:GEKnRwkWDdf9dOmKcNrar9EA1bz1z9DqPIO1+iLzhd8=
github.com/PaesslerAG/gval v1.0.0/go.mod h1:y/nm5yEyTeX6av0OfKJNp9rBNj2XrGhAf5+v24IBN1I=
github.com/PaesslerAG/jsonpath v0.1.0/go.mod h1:4BzmtoM/PI8fPO4aQGIusjGxGir2BzcV0grWtFzq1Y8=
github.com/PaesslerAG/jsonpath v0.1.1 h1:c1/AToHQMVsduPAa4Vh6xp2U0evy4t8SWp8imEsylIk=
github.com/PaesslerAG/jsonpath v0.1.1/go.mod h1:lVboNxFGal/VwW6d9JzIy56bUsYAP6tH/x80vjnCseY=
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2 h1:40yUSXwdkWN851BHCq6uiDhleh7A4+0yIBS+IUAqZVY=
github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.2/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M=
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9ag=
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -24,6 +37,12 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09 h1:MJFqtdxTq94XqUgg7DcGCaOIXrDTJE/tPHK66Jshguc=
github.com/disintegration/imaging v1.6.3-0.20201218193011-d40f48ce0f09/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9 h1:Ksd3Vil12b/ykT5NjGzd3qNc688cJB9OUXJZZChEktI=
github.com/dop251/goja v0.0.0-20260926152631-39ec2650adc9/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/esimov/pigo v1.4.7-0.20240801095032-7465ed14de47 h1:48iGRx9HamDuG4pCbPG5IXt4bKHhgn33KGynzHUgeIA=
@@ -54,6 +73,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -62,7 +83,11 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -177,6 +202,7 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
@@ -191,33 +217,104 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+4
View File
@@ -33,6 +33,7 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("database.cache_size", -40000)
v.SetDefault("database.max_open_conns", defaultDatabaseMaxOpenConns)
v.SetDefault("database.max_idle_conns", defaultDatabaseMaxIdleConns)
v.SetDefault("database.postgres_trgm_index", true)
v.SetDefault("secrets.jwt_secret", "")
@@ -52,6 +53,9 @@ func setDefaults(v *viper.Viper) {
v.SetDefault("cache.redis_prefix", "mebox")
v.SetDefault("cache.media_ttl_seconds", 90)
v.SetDefault("cache.emby_latest_ttl_seconds", 300)
v.SetDefault("cache.reader_content_max_size_mb", 1024)
v.SetDefault("cache.reader_content_ttl_hours", 168)
v.SetDefault("cache.reader_files_ttl_hours", 720)
v.SetDefault("search.backend", "")
v.SetDefault("search.opensearch_url", "")
+9
View File
@@ -62,6 +62,15 @@ func (c *Config) normalize() error {
if c.Cache.MediaTTLSeconds < 1 {
c.Cache.MediaTTLSeconds = 90
}
if c.Cache.ReaderContentMaxSizeMB < 0 {
c.Cache.ReaderContentMaxSizeMB = 0
}
if c.Cache.ReaderContentTTLHours < 0 {
c.Cache.ReaderContentTTLHours = 0
}
if c.Cache.ReaderFilesTTLHours < 0 {
c.Cache.ReaderFilesTTLHours = 0
}
c.Search.Backend = strings.ToLower(strings.TrimSpace(c.Search.Backend))
if c.Search.Index == "" {
c.Search.Index = "mebox_media"
+13 -1
View File
@@ -50,7 +50,8 @@ type AppConfig struct {
DataDir string `mapstructure:"data_dir"`
WebDir string `mapstructure:"web_dir"`
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
HTTPSEnabled bool `mapstructure:"https_enabled"`
// SSLCert 是 PEM 编码的 SSL 证书内容。
SSLCert string `mapstructure:"ssl_cert"`
@@ -86,6 +87,10 @@ type DatabaseConfig struct {
CacheSize int `mapstructure:"cache_size"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
// PostgresTrgmIndex 决定是否用 pg_trgm 的 GIN 三元组索引替代 SQLite 的
// FTS5 全文索引。开启后 ILIKE '%term%' 搜索能走索引而不是整表扫描,代价是
// media 表写入时要多维护 4 个 GIN 索引。仅对 PostgreSQL 生效。
PostgresTrgmIndex bool `mapstructure:"postgres_trgm_index"`
}
// SecretsConfig 保存 JWT / 第三方 API 密钥(不要提交值)。
@@ -133,6 +138,13 @@ type CacheConfig struct {
// 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机
// 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。
EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"`
// ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。
ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"`
// ReaderContentTTLHours 阅读正文缓存的保留时长(小时),0 表示不过期。
ReaderContentTTLHours int `mapstructure:"reader_content_ttl_hours"`
// ReaderFilesTTLHours 书源文件缓存(java.cacheFile / downloadFile)的保留时长(小时)。
// 0 表示使用内置兜底值(30 天)。
ReaderFilesTTLHours int `mapstructure:"reader_files_ttl_hours"`
}
type SearchConfig struct {
+1 -1
View File
@@ -165,7 +165,7 @@ func MigrateCurrentToPostgres(src *gorm.DB, targetDSN string, batchSize int, log
}
// 1. 初始化目标库 Schema、类型与索引
if err := AutoMigrate(targetDB); err != nil {
if err := AutoMigrateWithConfig(targetDB, nil, log); err != nil {
return nil, fmt.Errorf("auto migrate target PostgreSQL: %w", err)
}
@@ -0,0 +1,141 @@
package database
import (
"path/filepath"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
)
// legacyReaderSource 旧版书源结构(全局唯一,没有 user_id)。
type legacyReaderSource struct {
ID string `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Name string
SourceURL string `gorm:"type:varchar(512);uniqueIndex"`
RawJSON string
Enabled bool
EnabledExplore bool
Variables string
Header string
}
func (legacyReaderSource) TableName() string { return "reader_book_sources" }
// 迁移路径:老库(书源全局唯一、无 user_id)升级后
// 1. 旧唯一索引被删除,两个用户可以各持一份同 URL 的书源副本;
// 2. 书源按「谁书架上有这本书」回填归属,多个引用者各得一份副本;
// 3. 软删的历史书源行被清理,删除后重新导入不再撞唯一键。
func TestReaderSourceMigrationBackfill(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "legacy.db")), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
// Windows 上 sqlite 文件句柄会阻塞 TempDir 清理,测试结束前先关连接。
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
// 造老结构:只有书源表(带旧唯一索引),书籍表用当前模型建。
if err := db.AutoMigrate(&legacyReaderSource{}, &model.ReaderBook{}); err != nil {
t.Fatal(err)
}
shared := &legacyReaderSource{ID: "legacy-1", Name: "共享源", SourceURL: "https://shared.example.com", Enabled: true}
if err := db.Create(shared).Error; err != nil {
t.Fatal(err)
}
softDeleted := &legacyReaderSource{ID: "legacy-2", Name: "已删源", SourceURL: "https://gone.example.com"}
if err := db.Create(softDeleted).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(softDeleted).Error; err != nil { // 旧版 DeleteSource 的软删
t.Fatal(err)
}
// u1 两本、u2 一本都引用共享源;孤儿源无人引用。
books := []model.ReaderBook{
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u1", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/2", Name: "书2"},
{UserID: "u2", Origin: "https://shared.example.com", BookURL: "https://shared.example.com/b/1", Name: "书1"},
{UserID: "u2", Origin: "https://orphan.example.com", BookURL: "https://orphan.example.com/b/1", Name: "孤儿"},
}
for i := range books {
if err := db.Create(&books[i]).Error; err != nil {
t.Fatal(err)
}
}
// 迁移。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
t.Fatalf("删旧索引失败: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("AutoMigrate 失败: %v", err)
}
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatalf("回填失败: %v", err)
}
// 1. 旧唯一索引已删除:两个用户同 URL 各自一份。
var rows []model.ReaderBookSource
if err := db.Order("user_id ASC").Find(&rows).Error; err != nil {
t.Fatal(err)
}
byUser := map[string]model.ReaderBookSource{}
for _, r := range rows {
if r.SourceURL == "https://shared.example.com" {
byUser[r.UserID] = r
}
}
if len(byUser) != 2 {
t.Fatalf("共享源应有两份副本,实际 %d 份: %+v", len(byUser), byUser)
}
if byUser["u1"].ID == "" || byUser["u2"].ID == "" || byUser["u1"].ID == byUser["u2"].ID {
t.Fatalf("两份副本应有不同行 ID: u1=%q u2=%q", byUser["u1"].ID, byUser["u2"].ID)
}
if byUser["u1"].RawJSON != shared.RawJSON || byUser["u1"].Name != "共享源" {
t.Fatal("副本应保留原书源内容")
}
// 引用最多的用户保留原行(u1 有两本书)。
if byUser["u1"].ID != "legacy-1" {
t.Fatalf("原行应归引用最多的用户,实际 %q", byUser["u1"].ID)
}
// 2. 软删的历史行被清理。
var softLeft int64
if err := db.Unscoped().Model(&model.ReaderBookSource{}).
Where("source_url = ?", "https://gone.example.com").Count(&softLeft).Error; err != nil {
t.Fatal(err)
}
if softLeft != 0 {
t.Fatalf("软删的历史书源应被清理,剩余 %d 行", softLeft)
}
// 3. 无人引用的书源保持无归属(不会凭空分配给谁)。
var orphans int64
if err := db.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND user_id <> ''", "https://orphan.example.com").Count(&orphans).Error; err != nil {
t.Fatal(err)
}
if orphans != 0 {
t.Fatal("无人引用的书源不应被分配归属")
}
// 4. 回填是幂等的(再次执行不产生重复副本)。
if err := backfillReaderSourceOwnership(db); err != nil {
t.Fatal(err)
}
var total int64
if err := db.Model(&model.ReaderBookSource{}).Where("source_url = ?", "https://shared.example.com").Count(&total).Error; err != nil {
t.Fatal(err)
}
if total != 2 {
t.Fatalf("重复回填不应新增副本,实际 %d 份", total)
}
}
+286 -9
View File
@@ -1,27 +1,52 @@
package database
import (
"fmt"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
// AutoMigrate creates tables for every model registered in the model package.
func AutoMigrate(db *gorm.DB) error {
return AutoMigrateWithConfig(db, nil, nil)
}
// AutoMigrateWithConfig 与 AutoMigrate 相同,另外接受配置与日志:
// 配置用来决定可选的 PostgreSQL 搜索索引,日志用来记录耗时步骤
// (首次在已有大库上重建索引可能需要数十秒)。两者都可以为 nil。
func AutoMigrateWithConfig(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
// 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行,
// 不去重会导致唯一索引 uniq_user_history 创建失败。
if err := dedupePlaybackHistories(db); err != nil {
return err
}
// 书源从「全局唯一」改成「按用户独立」:先删掉 source_url 上的旧唯一索引。
// AutoMigrate 只会按索引名创建新索引,不会删除已改名的旧唯一索引,留着它
// 第二个用户导入同一书源就会撞唯一约束。
if err := dropLegacyReaderSourceUniqueIndex(db); err != nil {
return err
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
return err
}
// 回填书源归属:老库的书源没有 user_id,按「谁书架上有这本书」分配或复制副本。
if err := backfillReaderSourceOwnership(db); err != nil {
return err
}
if err := ensurePostgresColumnCompatibility(db); err != nil {
return err
}
if err := ensurePerformanceIndexes(db); err != nil {
return err
}
if err := ensureMediaPathIndex(db); err != nil {
return err
}
if err := ensureLibraryRootsCompatibility(db); err != nil {
return err
}
@@ -34,6 +59,111 @@ func AutoMigrate(db *gorm.DB) error {
}
return ensureSQLiteQueryOptimizer(db)
}
return ensurePostgresSearchIndexes(db, cfg, log)
}
// ensureMediaPathIndex 建立 media.path 的唯一约束与等值查找索引。
//
// 模型上不再用 gorm:"uniqueIndex"(见 model.Media.Path 的注释):PostgreSQL 的
// btree 单条索引项上限是 2704 字节,varchar(1024) 在中文路径下可达 3072 字节,
// 对整列建唯一索引会让这类行插入时报
//
// index row size ... exceeds btree version 4 maximum 2704
//
// 所以 PostgreSQL 改用 md5(path) 的表达式唯一索引(定长 32 字节,永远塞得下),
// 同时保留一条普通索引 idx_media_path 供 path = ? 等值查找(扫描与 upsert 的
// 热路径)使用。SQLite 没有这个长度限制,沿用整列唯一索引。
func ensureMediaPathIndex(db *gorm.DB) error {
if db == nil || !db.Migrator().HasTable("media") {
return nil
}
if !isPostgres(db) {
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// 先建哈希唯一索引:此时旧索引还在,唯一性不会出现空窗。
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_media_path_hash ON media(md5(path))`).Error; err != nil {
return err
}
unique, found, err := postgresIndexIsUnique(db, "idx_media_path")
if err != nil {
return err
}
if found && unique {
if err := db.Exec(`DROP INDEX IF EXISTS idx_media_path`).Error; err != nil {
return err
}
}
return db.Exec(`CREATE INDEX IF NOT EXISTS idx_media_path ON media(path)`).Error
}
// postgresIndexIsUnique 报告当前 schema 下是否存在该索引、以及它是否唯一。
func postgresIndexIsUnique(db *gorm.DB, indexName string) (unique bool, found bool, err error) {
// 用指针接 bool:没有匹配行时 Scan 不报错,nil 即代表索引不存在。
var row struct {
IsUnique *bool
}
if err := db.Raw(`
SELECT i.indisunique AS is_unique
FROM pg_class c
JOIN pg_index i ON i.indexrelid = c.oid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema() AND c.relname = ?
`, indexName).Scan(&row).Error; err != nil {
return false, false, err
}
if row.IsUnique == nil {
return false, false, nil
}
return *row.IsUnique, true, nil
}
// ensurePostgresSearchIndexes 给 PostgreSQL 补上 SQLite FTS5 的替代品。
//
// 全文检索只在 SQLite 下存在(FTS5 虚表 + 触发器),PostgreSQL 下
// media_search_repository 会退回 title/original_name/path/genres 的 ILIKE
// 全表扫描。pg_trgm 的 GIN 三元组索引能让这类 '%term%' 模式(≥3 字符)走索引,
// 把搜索从整表扫描降到索引扫描。
//
// 之所以可关闭:media 表写入非常频繁(每次扫描批量入库),多 4 个 GIN 索引会
// 明显放大写入成本。默认开启以对齐 SQLite 的搜索能力,写入压力大的部署可以关掉。
//
// 整个过程是尽力而为:没有建扩展的权限、Postgres 版本不支持等情况下只记一条
// 警告并继续启动,绝不让一个搜索加速项把服务挡在门外。
func ensurePostgresSearchIndexes(db *gorm.DB, cfg *config.Config, log *zap.Logger) error {
if !isPostgres(db) {
return nil
}
if cfg != nil && !cfg.Database.PostgresTrgmIndex {
if log != nil {
log.Info("postgres trigram search index disabled; media search falls back to sequential scan")
}
return nil
}
if err := db.Exec(`CREATE EXTENSION IF NOT EXISTS pg_trgm`).Error; err != nil {
if log != nil {
log.Warn("create pg_trgm extension failed; media search falls back to sequential scan", zap.Error(err))
}
return nil
}
// 首次在已有大库上执行会重建索引(服务尚未开始接受请求),之后每次启动都因
// IF NOT EXISTS 直接跳过。
if log != nil {
log.Info("ensuring postgres trigram search indexes")
}
for _, stmt := range []string{
`CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_original_name_trgm ON media USING gin (original_name gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_path_trgm ON media USING gin (path gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_media_genres_trgm ON media USING gin (genres gin_trgm_ops)`,
} {
if err := db.Exec(stmt).Error; err != nil {
if log != nil {
log.Warn("create trigram index failed; media search may fall back to sequential scan",
zap.String("statement", stmt), zap.Error(err))
}
return nil
}
}
return nil
}
@@ -42,6 +172,105 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error {
return db.Exec("ANALYZE").Error
}
// dropLegacyReaderSourceUniqueIndex 删除书源表上旧版 source_url 唯一索引。
//
// 书源改为按用户独立后唯一键是 (user_id, source_url);SQLite 的 RenameTable 与
// AutoMigrate 都不会删除按旧名创建的索引(实测旧唯一索引在改名后依然生效),
// 必须显式删除,否则第二个用户导入同一书源会撞唯一约束。
func dropLegacyReaderSourceUniqueIndex(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
for _, name := range []string{"idx_reader_book_sources_source_url", "reader_book_sources_source_url_key"} {
if db.Migrator().HasIndex(&model.ReaderBookSource{}, name) {
if err := db.Migrator().DropIndex(&model.ReaderBookSource{}, name); err != nil {
return err
}
}
}
// 旧版 DeleteSource 是软删:这些历史行会继续占用 (user_id, source_url) 唯一键,
// 让「删掉再导入同一书源」失败。书源是可重新导入的数据,这里一次性清掉。
return db.Unscoped().Where("deleted_at IS NOT NULL").Delete(&model.ReaderBookSource{}).Error
}
// backfillReaderSourceOwnership 给老库的书源补上归属用户。
//
// 书源原先全局唯一,现在按用户独立:按「谁的书架上有这本书」把书源分配给该用户;
// 同一书源被多个用户引用时,给每个用户复制一份副本(各自启停/排序/改规则),
// 这样谁都不会因为别人删源而失去书源。
//
// 没有 user_id 且无人引用的书源保持原样(user_id 为空)——它不再出现在任何人的
// 列表里,但数据仍在,需要时可以人工认领。
func backfillReaderSourceOwnership(db *gorm.DB) error {
if !db.Migrator().HasTable(&model.ReaderBookSource{}) {
return nil
}
var pending []model.ReaderBookSource
if err := db.Where("user_id = '' OR user_id IS NULL").Find(&pending).Error; err != nil {
return err
}
for i := range pending {
src := pending[i]
if err := assignOrCloneReaderSource(db, &src); err != nil {
return err
}
}
return nil
}
func assignOrCloneReaderSource(db *gorm.DB, src *model.ReaderBookSource) error {
if src == nil || src.SourceURL == "" {
return nil
}
// 引用该书源的书籍分布(去重到用户)。
type ownerCount struct {
UserID string
N int64
}
var owners []ownerCount
if err := db.Model(&model.ReaderBook{}).
Select("user_id, COUNT(*) AS n").
Where("origin = ?", src.SourceURL).
Group("user_id").Order("n DESC").Scan(&owners).Error; err != nil {
return err
}
if len(owners) == 0 {
return nil // 无人引用:保持无归属
}
// 第一个用户(引用最多)直接接手这一行。
owner := owners[0].UserID
if owner == "" {
return nil
}
if err := db.Model(&model.ReaderBookSource{}).
Where("id = ?", src.ID).
Updates(map[string]any{"user_id": owner}).Error; err != nil {
return err
}
// 其余用户各复制一份副本(新行 ID,其余字段全量拷贝)。
for _, o := range owners[1:] {
if o.UserID == "" || o.UserID == owner {
continue
}
var exists int64
if err := db.Model(&model.ReaderBookSource{}).
Where("user_id = ? AND source_url = ?", o.UserID, src.SourceURL).
Count(&exists).Error; err != nil {
return err
}
if exists > 0 {
continue
}
clone := *src
clone.ID = ""
clone.UserID = o.UserID
if err := db.Create(&clone).Error; err != nil {
return err
}
}
return nil
}
// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by
// the former read-then-write upsert, so the uniq_user_history composite unique
// index can be created on existing databases. Keeps the most recent row per
@@ -67,16 +296,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
if !isPostgres(db) {
return nil
}
statements := []string{
`ALTER TABLE media ALTER COLUMN container TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN genres TYPE text`,
`ALTER TABLE media ALTER COLUMN series_id TYPE varchar(128)`,
`ALTER TABLE media ALTER COLUMN duplicate_of TYPE varchar(128)`,
`ALTER TABLE playback_histories ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE favorites ALTER COLUMN media_id TYPE varchar(128)`,
`ALTER TABLE playlist_items ALTER COLUMN media_id TYPE varchar(128)`,
// 只在类型确实不匹配时才 ALTER。
//
// PostgreSQL 的 ALTER COLUMN ... TYPE 只要类型声明与目标不同就会整表重写并
// 重建相关索引,执行期间持有 ACCESS EXCLUSIVE 锁。此前这里每次启动都无条件
// 发这 7 条语句:类型其实早已一致,但几十万行的 media 表仍然被反复重写,
// 启动会卡住数分钟,且期间这张表连读都被阻塞。
changes := []struct {
table string
column string
target string
}{
{"media", "container", "varchar(128)"},
{"media", "genres", "text"},
{"media", "series_id", "varchar(128)"},
{"media", "duplicate_of", "varchar(128)"},
{"playback_histories", "media_id", "varchar(128)"},
{"favorites", "media_id", "varchar(128)"},
{"playlist_items", "media_id", "varchar(128)"},
}
for _, stmt := range statements {
for _, change := range changes {
current, found, err := postgresColumnType(db, change.table, change.column)
if err != nil {
return err
}
if !found || current == change.target {
continue
}
stmt := "ALTER TABLE " + quoteIdent(change.table) +
" ALTER COLUMN " + quoteIdent(change.column) + " TYPE " + change.target
if err := db.Exec(stmt).Error; err != nil {
return err
}
@@ -84,6 +332,35 @@ func ensurePostgresColumnCompatibility(db *gorm.DB) error {
return nil
}
// postgresColumnType 返回列在当前 schema 下的规范化类型描述,例如
// "varchar(128)" / "text"。列不存在时 found 为 false。
func postgresColumnType(db *gorm.DB, table, column string) (value string, found bool, err error) {
var row struct {
DataType string
MaxLength *int64
}
if err := db.Raw(`
SELECT data_type AS data_type, character_maximum_length AS max_length
FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = ? AND column_name = ?
`, table, column).Scan(&row).Error; err != nil {
return "", false, fmt.Errorf("inspect postgres column %s.%s: %w", table, column, err)
}
dataType := strings.ToLower(strings.TrimSpace(row.DataType))
if dataType == "" {
return "", false, nil
}
// information_schema 用的是 SQL 标准名,映射回 GORM 标签里的写法再比较。
switch dataType {
case "character varying", "varchar":
dataType = "varchar"
}
if row.MaxLength != nil && *row.MaxLength > 0 {
dataType = fmt.Sprintf("%s(%d)", dataType, *row.MaxLength)
}
return dataType, true, nil
}
func ensurePerformanceIndexes(db *gorm.DB) error {
statements := []string{
// 完整多级排序索引:媒体库分页与首页预览的 ORDER BY
@@ -0,0 +1,54 @@
package database
import (
"path/filepath"
"strings"
"testing"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/truewhile/MeBox/internal/model"
)
// TestAutoMigrateCreatesMediaPathUniqueIndex 守住 media.path 的唯一约束。
//
// 模型上不再声明 gorm:"uniqueIndex"(PostgreSQL 的 btree 单条索引项上限是
// 2704 字节,而 varchar(1024) 在中文路径下可达 3072 字节,对整列建唯一索引会让
// 这类行插入直接失败),改由 AutoMigrate 里的 ensureMediaPathIndex 按方言显式
// 建立。一旦这个调用被漏掉,扫描会静默产生重复的媒体行(同一文件被入库多次)。
func TestAutoMigrateCreatesMediaPathUniqueIndex(t *testing.T) {
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "mebox.db")), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatal(err)
}
// 必须在 TempDir 清理之前关掉连接,否则 Windows 上删除库文件会失败。
// t.Cleanup 是后进先出:这里注册的关闭早于 t.TempDir 注册的删除。
if sqlDB, err := db.DB(); err == nil {
t.Cleanup(func() { _ = sqlDB.Close() })
}
if err := AutoMigrate(db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
var definition string
if err := db.Raw(`SELECT COALESCE(sql, '') FROM sqlite_master WHERE type = 'index' AND name = 'idx_media_path'`).Scan(&definition).Error; err != nil {
t.Fatal(err)
}
if definition == "" {
t.Fatal("AutoMigrate did not create idx_media_path")
}
if !strings.Contains(strings.ToUpper(definition), "UNIQUE") {
t.Fatalf("idx_media_path is not unique: %q", definition)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-1"}, Path: "/media/a.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-2"}, Path: "/media/a.mkv"}).Error; err == nil {
t.Fatal("a duplicate media path was accepted; the unique constraint is not enforced")
}
}
@@ -0,0 +1,58 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/service"
)
// 云 HLS 分片代理的错误映射:
// - 会话不存在/过期要给前端可判断的 code,前端据此重拉 master.m3u8 建会话续播,
// 而不是当成播放失败直接退回本地转码;
// - 客户端主动断开(seek、切清晰度、关页面取消在途请求)不是网关故障,记 499,
// 与 nginx 的口径一致。以前统一记 502,既污染监控又会让前端拿到误导性错误。
func newCloud115HLSSessionTestRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
svc := &service.Container{
Cloud115: service.NewCloud115PlaybackService(&config.Config{}, zap.NewNop(), nil, nil),
}
router := gin.New()
router.GET("/api/cloud115/hls/:session/:key", cloud115HLSSessionHandler(svc))
return router
}
func TestCloud115HLSSessionUnknownSessionReturnsCodedNotFound(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hls_session_not_found") {
t.Fatalf("expected machine-readable code in body, got %s", rec.Body.String())
}
}
func TestCloud115HLSSessionClientAbortReturns499(t *testing.T) {
router := newCloud115HLSSessionTestRouter()
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/unknown-session/abc?media_id=media-1", nil).
WithContext(ctx)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != 499 {
t.Fatalf("status = %d, want 499 (client closed request); body=%s", rec.Code, rec.Body.String())
}
}
+13 -2
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"errors"
"net/http"
"strconv"
@@ -136,11 +137,21 @@ func cloud115HLSSessionHandler(svc *service.Container) gin.HandlerFunc {
if err == nil || c.Writer.Written() {
return
}
// 客户端主动断开(播放器 seek、切切清晰度、关页面时取消在途请求)会让
// 上游请求以 context.Canceled 结束。这是正常现象,不是网关故障;以前
// 统一记成 502,既污染监控又和 nginx 的 499 对不上,这里按 499 记。
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
c.Request.Context().Err() != nil {
c.AbortWithStatus(499)
return
}
switch {
case errors.Is(err, service.ErrCloud115HLSSessionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "hls session not found"})
// code 供前端判断「会话没了,需要重新拉一次 master.m3u8 建会话」,
// 而不是直接放弃云端播放退回本地转码。
c.JSON(http.StatusNotFound, gin.H{"code": "hls_session_not_found", "error": "hls session not found"})
case errors.Is(err, service.ErrCloud115HLSUpstreamExpired):
c.JSON(http.StatusGone, gin.H{"error": "hls upstream expired"})
c.JSON(http.StatusGone, gin.H{"code": "hls_session_expired", "error": "hls upstream expired"})
default:
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
}
+9 -7
View File
@@ -86,7 +86,9 @@ func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[
if err != nil || m == nil {
return
}
raw := strings.TrimSpace(m.STRMURL)
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
raw := service.MediaSTRMTarget(m)
if raw == "" || !service.IsStrmMediaRow(m) {
return
}
@@ -409,9 +411,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
// 转码播放下每个分片请求都跑一遍太浪费。
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
@@ -436,9 +439,8 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
+14
View File
@@ -26,6 +26,20 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
// STRM 播放端点:strm 文件内容指向这里,Emby/Infuse 直接请求(无 JWT)。
api.GET("/strm/play/:provider/:file", strmPlayHandler(svc))
api.HEAD("/strm/play/:provider/:file", strmPlayHandler(svc))
// 阅读媒体代理:音频流/漫画图片,鉴权靠 HMAC 签名(audio/img 元素带不了 JWT)。
api.GET("/reader/media", readerMediaProxyHandler(svc))
// 本地书籍内嵌资源(EPUB 图片):同样靠 HMAC 签名鉴权。
api.GET("/reader/local/asset", readerLocalAssetHandler(svc))
// 本地有声书音频流:同样靠 HMAC 签名鉴权,服务端处理 Range。
api.GET("/reader/local/audio", readerLocalAudioHandler(svc))
api.HEAD("/reader/local/audio", readerLocalAudioHandler(svc))
// 需要转码的音轨(WMA 等浏览器解不了的格式):签名鉴权 + 服务端 ffmpeg 转码。
api.GET("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
api.HEAD("/reader/audio/transcode", readerAudioTranscodeHandler(svc))
// 阅读书源浏览器页面与资源代理:java.startBrowserAwait 承载的页面填进
// <iframe src>,同样带不了 JWT,鉴权靠绑定待办 ID 的 HMAC 签名。
api.GET("/reader/browser/page", readerBrowserPageHandler(svc))
api.GET("/reader/browser/asset", readerBrowserAssetHandler(svc))
// 115 中继/CloudDrive 授权回跳(authorization_id 会话 + 共享密钥校验)
api.POST("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
api.GET("/strm/oauth/callback", strm115OAuthCallbackHandler(svc))
+12
View File
@@ -12,6 +12,8 @@ import (
"net/url"
"strings"
"go.uber.org/zap"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
@@ -173,10 +175,20 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
if svc.Log != nil {
svc.Log.Warn("external playback token: user lookup failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
if err != nil {
// 静默返回空串会让 stream_url 变成 ?token=(空)、外部播放器只看到
// 401 无从排查,至少在服务端日志里留下原因。
if svc.Log != nil {
svc.Log.Warn("issue external playback token failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
return token
+247
View File
@@ -0,0 +1,247 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/service"
)
// 书源宿主浏览器的 HTTP 层测试:轮询待办 → 承载页面 → 回传 DOM。
//
// 这条链路对应真实书源的「切换线路」「用户后台」:
// java.startBrowserAwait 会阻塞在 POST /sources/:id/login 上,
// 前端必须能从 /reader/browser/pending 拿到页面,再把 DOM POST 回去。
// browserHandlerSource 构造一个用 startBrowserAwait 切换线路的书源。
func browserHandlerSource(sourceURL string) string {
loginJS := `function switchLine() {
let html = '<!DOCTYPE html><html><head></head><body><span id="serverValue">线路甲</span></body></html>';
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
}`
src := map[string]any{
"bookSourceUrl": sourceURL,
"bookSourceName": "浏览器面板源",
"loginUrl": loginJS,
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
}
out, _ := json.Marshal(src)
return string(out)
}
// registerBrowserRoutesForTest 挂载阅读路由 + 承载页面/资源代理(跳过鉴权)。
// 生产环境里后两个是公开路由(iframe 带不了 JWT),鉴权靠 HMAC 签名。
func registerBrowserRoutesForTest(cfg *service.Container) *gin.Engine {
r := registerReaderRoutesForTest(cfg)
r.GET("/api/reader/browser/page", readerBrowserPageHandler(cfg))
r.GET("/api/reader/browser/asset", readerBrowserAssetHandler(cfg))
return r
}
func TestReaderBrowserPanelRoutes(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
// 导入书源
body, _ := json.Marshal(map[string]string{"text": browserHandlerSource("https://panel.example.com")})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
var list struct {
Sources []struct {
ID string `json:"id"`
} `json:"sources"`
}
_ = json.Unmarshal(w.Body.Bytes(), &list)
if len(list.Sources) != 1 {
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
}
sourceID := list.Sources[0].ID
// 执行切换线路:这个请求会阻塞等待用户操作,放到后台跑
actionDone := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login",
strings.NewReader(`{"action":"switchLine()","fields":{}}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
actionDone <- rec
}()
// 轮询待办页面
var page struct {
ID string `json:"id"`
Title string `json:"title"`
Mode string `json:"mode"`
PageURL string `json:"page_url"`
SourceID string `json:"source_id"`
}
deadline := time.Now().Add(5 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatal("等待超时:未出现待办页面")
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
"/api/reader/browser/pending?source_id="+sourceID, nil))
if w.Code != http.StatusOK {
t.Fatalf("轮询 status=%d body=%s", w.Code, w.Body.String())
}
var pending struct {
Pages []json.RawMessage `json:"pages"`
}
if err := json.Unmarshal(w.Body.Bytes(), &pending); err != nil {
t.Fatalf("待办返回不是 JSON: %s", w.Body.String())
}
if len(pending.Pages) > 0 {
if err := json.Unmarshal(pending.Pages[0], &page); err != nil {
t.Fatal(err)
}
break
}
time.Sleep(20 * time.Millisecond)
}
if page.Mode != "wait" || page.Title != "线路设置" {
t.Fatalf("待办描述异常: %+v", page)
}
if page.SourceID != sourceID {
t.Fatalf("待办应带书源 ID: %+v", page)
}
// 承载页面:签名正确应返回 HTML,签名错误应 403
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, page.PageURL, nil))
if w.Code != http.StatusOK {
t.Fatalf("承载页面 status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `id="serverValue"`) {
t.Fatalf("承载页面内容异常: %s", w.Body.String())
}
// 服务端注入的 DOM 回传脚本必须在(父窗口靠它取回用户操作后的页面)
if !strings.Contains(w.Body.String(), "data-mebox-browser-bridge") {
t.Fatalf("承载页面缺少 DOM 回传脚本: %s", w.Body.String())
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/page?id="+page.ID+"&s=forged", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("伪造签名应 403,实际 %d", w.Code)
}
// 回传用户操作后的 DOM(模拟点了 √)
result, _ := json.Marshal(map[string]any{
"id": page.ID,
"body": `<html><body><span id="serverValue">线路乙</span></body></html>`,
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(string(result)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("回传结果 status=%d body=%s", w.Code, w.Body.String())
}
// 阻塞的登录动作应被放行
select {
case rec := <-actionDone:
if rec.Code != http.StatusOK {
t.Fatalf("登录动作 status=%d body=%s", rec.Code, rec.Body.String())
}
var res struct {
OK bool `json:"ok"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &res)
if !res.OK {
t.Fatalf("动作未成功: %s", rec.Body.String())
}
case <-time.After(5 * time.Second):
t.Fatal("回传后阻塞未解除")
}
// 线路应已写进源变量
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
var info struct {
Variable string `json:"variable"`
}
_ = json.Unmarshal(w.Body.Bytes(), &info)
if !strings.Contains(info.Variable, "线路乙") {
t.Fatalf("线路未写入源变量: %q", info.Variable)
}
// 完成后待办清空
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet,
"/api/reader/browser/pending?source_id="+sourceID, nil))
var after struct {
Pages []json.RawMessage `json:"pages"`
}
_ = json.Unmarshal(w.Body.Bytes(), &after)
if len(after.Pages) != 0 {
t.Fatalf("完成后待办未清理: %s", w.Body.String())
}
}
func TestReaderBrowserResultRejectsUnknownPage(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result",
strings.NewReader(`{"id":"not-a-real-page","body":"x"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("未知待办应 400,实际 %d body=%s", w.Code, w.Body.String())
}
}
func TestReaderBrowserResultRequiresID(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/browser/result", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("缺少 id 应 400,实际 %d", w.Code)
}
}
// TestReaderBrowserPageBadQuery 承载地址参数缺失/非法时不应 500。
func TestReaderBrowserPageBadQuery(t *testing.T) {
container := newReaderHandlerContainer(t)
router := registerBrowserRoutesForTest(container)
for _, target := range []string{"/api/reader/browser/page", "/api/reader/browser/page?id=x",
"/api/reader/browser/asset", "/api/reader/browser/asset?id=x&u=!!!&s=y"} {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("%s 应 403,实际 %d", target, w.Code)
}
}
// 合法 base64 但不匹配的签名
w := httptest.NewRecorder()
q := url.Values{"id": {"x"}, "u": {"aHR0cHM6Ly9leGFtcGxlLmNvbS8"}, "s": {"deadbeef"}}
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/browser/asset?"+q.Encode(), nil))
if w.Code != http.StatusForbidden {
t.Fatalf("签名不匹配应 403,实际 %d", w.Code)
}
}
+40
View File
@@ -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)
}
}
}
+240
View File
@@ -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)
}
}
+174
View File
@@ -0,0 +1,174 @@
package handler
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
// TestReaderLocalAudioStream 本地有声书音频流端点:
// 签名鉴权 + Range 透传(播放器拖进度靠它)。
func TestReaderLocalAudioStream(t *testing.T) {
container := newReaderHandlerContainer(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
router.HEAD("/api/reader/local/audio", readerLocalAudioHandler(container))
payload := []byte("0123456789abcdefghij")
path := filepath.Join(t.TempDir(), "track.mp3")
if err := os.WriteFile(path, payload, 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.LocalAudioURL("book-1", path)
// 全量拉取
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusOK {
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
}
if w.Body.String() != string(payload) {
t.Fatalf("全量拉取 body=%q", w.Body.String())
}
// Range 请求要能拖进度
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
req.Header.Set("Range", "bytes=2-5")
router.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("Range 请求 status=%d", w.Code)
}
if w.Body.String() != "2345" {
t.Fatalf("Range 请求 body=%q", w.Body.String())
}
// 篡改签名应被拒绝
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
}
// 文件不存在 → 404(而不是 500)
missing := container.Reader.LocalAudioURL("book-1", filepath.Join(t.TempDir(), "nope.mp3"))
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, missing, nil))
if w.Code != http.StatusNotFound {
t.Fatalf("缺失文件 status=%d,期望 404", w.Code)
}
}
// TestReaderAudioTranscodeRoute 转码端点:签名鉴权,命中缓存后按 Range 下发。
// 用预置的缓存文件代替真实转码,避免测试依赖 ffmpeg。
func TestReaderAudioTranscodeRoute(t *testing.T) {
container := newReaderHandlerContainer(t)
container.Cfg.App.DataDir = t.TempDir() // 转码缓存落在临时目录,别写进仓库
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
router.HEAD("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
const bookID = "book-1"
source := `D:\media\斗破苍穹\001.wma`
payload := []byte("ID3FAKEMP3PAYLOAD0123456789")
cachePath, err := container.Reader.AudioTranscodeCachePath(bookID, source)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cachePath, payload, 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.AudioTranscodeURL(bookID, source)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusOK {
t.Fatalf("全量拉取 status=%d body=%s", w.Code, w.Body.String())
}
if w.Body.String() != string(payload) {
t.Fatalf("全量拉取 body=%q", w.Body.String())
}
// 拖进度靠 Range
w = httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, signed, nil)
req.Header.Set("Range", "bytes=3-7")
router.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("Range 请求 status=%d", w.Code)
}
if w.Body.String() != "FAKEM" {
t.Fatalf("Range 请求 body=%q", w.Body.String())
}
// 篡改签名应被拒绝
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed+"x", nil))
if w.Code != http.StatusForbidden {
t.Fatalf("坏签名 status=%d,期望 403", w.Code)
}
// 换一本书(签名不匹配)同样拒绝
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("换书后 status=%d,期望 403", w.Code)
}
}
// TestReaderAudioTranscodeRouteWithoutFFmpeg 没装 ffmpeg 且无缓存时返回明确错误,
// 而不是空响应(前端据此提示用户去装 ffmpeg)。
func TestReaderAudioTranscodeRouteWithoutFFmpeg(t *testing.T) {
container := newReaderHandlerContainer(t)
container.Cfg.App.DataDir = t.TempDir()
container.Cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/audio/transcode", readerAudioTranscodeHandler(container))
signed := container.Reader.AudioTranscodeURL("book-1", `D:\media\001.wma`)
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, signed, nil))
if w.Code != http.StatusBadGateway {
t.Fatalf("status=%d,期望 502", w.Code)
}
if !strings.Contains(w.Body.String(), "ffmpeg") {
t.Fatalf("错误正文应提到 ffmpeg: %q", w.Body.String())
}
}
// TestReaderLocalAudioRejectsOtherBook 换一本书的签名不通用:防止拿到别人的音频地址。
func TestReaderLocalAudioRejectsOtherBook(t *testing.T) {
container := newReaderHandlerContainer(t)
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/reader/local/audio", readerLocalAudioHandler(container))
path := filepath.Join(t.TempDir(), "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o640); err != nil {
t.Fatal(err)
}
signed := container.Reader.LocalAudioURL("book-1", path)
forged := strings.Replace(signed, "b=book-1", "b=book-2", 1)
if forged == signed {
t.Fatal("测试用例未改写 book 参数")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, forged, nil))
if w.Code != http.StatusForbidden {
t.Fatalf("换书后签名应失效,status=%d", w.Code)
}
}
+307
View File
@@ -0,0 +1,307 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"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"
"github.com/truewhile/MeBox/internal/service"
"github.com/truewhile/MeBox/internal/service/reader"
)
// 书源登录相关的 HTTP 层测试:路由注册与 JSON 契约。
func newReaderHandlerContainer(t *testing.T) *service.Container {
t.Helper()
// 唯一库名 + cache=shared:阅读链路会并发写会话状态,
// 而 :memory: 下每个连接各自一个库,并发写入对后续读取不可见。
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
return &service.Container{
Repo: repos,
Cfg: cfg,
Log: zap.NewNop(),
Reader: reader.NewReaderService(cfg, zap.NewNop(), repos),
}
}
// loginHandlerSource 返回一个声明了 loginUrl/loginUi 的书源 JSON。
func loginHandlerSource(server string) string {
loginJS := `function login(flag) {
var payload = JSON.stringify({register_email: result['邮箱'], password: result['密码']});
var res = java.ajax(baseUrl + '/login_api,{"method":"POST","headers":{"Content-Type":"application/json"},"body":' + JSON.stringify(payload) + '}');
var data = JSON.parse(res);
if (data.code == 0) { cookie.setCookie(baseUrl, 'qttoken=' + data.key); java.toast('登录成功'); return true; }
java.toast(data.msg || '登录失败'); return false;
}`
ui, _ := json.Marshal([]map[string]any{
{"name": "邮箱", "type": "text"},
{"name": "密码", "type": "password"},
{"name": "登录", "type": "button", "action": "login(true)"},
})
src := map[string]any{
"bookSourceUrl": server,
"bookSourceName": "登录源",
"bookSourceType": 0,
"loginUrl": loginJS,
"loginUi": string(ui),
"searchUrl": server + "/search",
"ruleSearch": map[string]any{
"bookList": "class.item",
"name": "tag.h3@tag.a@text",
"bookUrl": "tag.h3@tag.a@href",
},
}
b, _ := json.Marshal(src)
return string(b)
}
// loginHandlerServer 模拟需要登录的站点。
func loginHandlerServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login_api":
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
if body["register_email"] != "u@e.com" || body["password"] != "pw" {
_, _ = w.Write([]byte(`{"code":1,"msg":"账号或密码错误"}`))
return
}
http.SetCookie(w, &http.Cookie{Name: "qttoken", Value: "HTOKEN_1234567890", Path: "/"})
_, _ = w.Write([]byte(`{"code":0,"key":"HTOKEN_1234567890"}`))
case "/search":
if !strings.Contains(r.Header.Get("Cookie"), "qttoken=HTOKEN_1234567890") {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<html>未登录</html>`))
return
}
_, _ = w.Write([]byte(`<html><body><div class="item"><h3><a href="/book/1">登录后可见</a></h3></div></body></html>`))
default:
http.NotFound(w, r)
}
}))
}
// registerReaderRoutesForTest 挂载阅读路由(跳过鉴权中间件)。
func registerReaderRoutesForTest(cfg *service.Container) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
registerReaderRoutes(r.Group("/api"), cfg)
return r
}
// TestReaderLoginRoutesEndToEnd 走完整 HTTP 路由:
// 查看登录信息 → 执行登录 → 搜索携带 Cookie 成功。
func TestReaderLoginRoutesEndToEnd(t *testing.T) {
srv := loginHandlerServer(t)
defer srv.Close()
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
// 导入书源
body, _ := json.Marshal(map[string]string{"text": loginHandlerSource(srv.URL)})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("导入书源 status=%d body=%s", w.Code, w.Body.String())
}
// 列表应标注 has_login
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
if w.Code != http.StatusOK {
t.Fatalf("书源列表 status=%d", w.Code)
}
var listResp struct {
Sources []struct {
ID string `json:"id"`
HasLogin bool `json:"has_login"`
} `json:"sources"`
}
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil || len(listResp.Sources) != 1 {
t.Fatalf("书源列表解析失败: %s", w.Body.String())
}
if !listResp.Sources[0].HasLogin {
t.Fatal("has_login 应为 true")
}
sourceID := listResp.Sources[0].ID
// GET 登录信息:应返回表单字段与未登录状态
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登录信息 status=%d body=%s", w.Code, w.Body.String())
}
var info struct {
HasLoginJS bool `json:"has_login_js"`
LoggedIn bool `json:"logged_in"`
Fields []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"fields"`
}
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatalf("登录信息解析失败: %s", w.Body.String())
}
if !info.HasLoginJS || info.LoggedIn {
t.Fatalf("初始状态异常: %+v", info)
}
var names []string
for _, f := range info.Fields {
names = append(names, f.Name)
}
for _, want := range []string{"邮箱", "密码", "登录"} {
if !slices.Contains(names, want) {
t.Fatalf("缺少表单字段 %q: %v", want, names)
}
}
// 密码错误 → ok=false,提示透传
loginBody, _ := json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "bad"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var failRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Toasts []string `json:"toasts"`
}
_ = json.Unmarshal(w.Body.Bytes(), &failRes)
if failRes.LoggedIn {
t.Fatalf("密码错误不应登录成功: %s", w.Body.String())
}
if !strings.Contains(strings.Join(failRes.Toasts, " "), "密码错误") {
t.Fatalf("失败提示未透传: %s", w.Body.String())
}
// 正确密码 → ok=true 且已登录
loginBody, _ = json.Marshal(map[string]any{
"fields": map[string]string{"邮箱": "u@e.com", "密码": "pw"},
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/sources/"+sourceID+"/login", strings.NewReader(string(loginBody)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("登录 status=%d body=%s", w.Code, w.Body.String())
}
var okRes struct {
OK bool `json:"ok"`
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
if err := json.Unmarshal(w.Body.Bytes(), &okRes); err != nil {
t.Fatal(err)
}
if !okRes.OK || !okRes.LoggedIn || len(okRes.Cookies) == 0 {
t.Fatalf("登录应成功并带 Cookie: %s", w.Body.String())
}
// 搜索应携带 Cookie 并拿到结果
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/reader/search", strings.NewReader(`{"key":"任意"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
var searchRes struct {
Books []struct{ Name string } `json:"books"`
Skipped []struct {
Reason string `json:"reason"`
} `json:"skipped"`
}
_ = json.Unmarshal(w.Body.Bytes(), &searchRes)
if len(searchRes.Books) != 1 || searchRes.Books[0].Name != "登录后可见" {
t.Fatalf("登录后搜索应成功: %s", w.Body.String())
}
// 登出 → 登录态清空
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/reader/sources/"+sourceID+"/login", nil))
if w.Code != http.StatusOK {
t.Fatalf("登出 status=%d", w.Code)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources/"+sourceID+"/login", nil))
var afterLogout struct {
LoggedIn bool `json:"logged_in"`
Cookies map[string]string `json:"cookies"`
}
_ = json.Unmarshal(w.Body.Bytes(), &afterLogout)
if afterLogout.LoggedIn || len(afterLogout.Cookies) != 0 {
t.Fatalf("登出后应无登录态: %s", w.Body.String())
}
}
// TestReaderSetSourceVariableRoute 源变量接口应保存合法 JSON 并拒绝非法 JSON。
func TestReaderSetSourceVariableRoute(t *testing.T) {
cfg := newReaderHandlerContainer(t)
router := registerReaderRoutesForTest(cfg)
body, _ := json.Marshal(map[string]string{"text": `{"bookSourceUrl":"https://v.example.com","bookSourceName":"变量源"}`})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/reader/sources/import", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/reader/sources", nil))
var list struct {
Sources []struct {
ID string `json:"id"`
} `json:"sources"`
}
_ = json.Unmarshal(w.Body.Bytes(), &list)
if len(list.Sources) != 1 {
t.Fatalf("应导入 1 个书源: %s", w.Body.String())
}
id := list.Sources[0].ID
// 合法 JSON
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"{\"线路\":\"https://v2.example.com\"}"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("保存变量 status=%d body=%s", w.Code, w.Body.String())
}
// 非法 JSON 应 400
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/reader/sources/"+id+"/variable",
strings.NewReader(`{"variable":"not-json"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("非法变量应 400,实际 %d body=%s", w.Code, w.Body.String())
}
}
+248
View File
@@ -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)
}
}
+170
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -30,4 +30,5 @@ func registerAuthenticatedRoutes(api *gin.RouterGroup, cfg *config.Config, svc *
registerAuthedDLNAControlRoutes(authed, svc)
registerAuthedFavoriteAndMediaActionRoutes(authed, svc)
registerAuthedPlaybackExtraRoutes(authed, svc)
registerReaderRoutes(authed, svc)
}
+92 -2
View File
@@ -3,14 +3,85 @@
package helper
import (
"bytes"
"compress/flate"
"compress/gzip"
"compress/zlib"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// defaultUserAgent 是默认浏览器 User-Agent(用于 HTTP 请求头)。
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
// maxDecompressedBody 解压后的响应体上限。
//
// 调用方只限制了压缩前的字节数(例如 reader 的 8MiB),而高压缩比响应可以
// 放大几个数量级:一个几 MB 的 gzip 就能在解压时把内存吃光。这里统一兜底,
// 超过上限时按解压失败处理(原样返回压缩字节,由调用方报错)。
const maxDecompressedBody = 32 << 20
// DecompressBody 兜底解压响应体(gzip / deflate)。
//
// 正常情况下用不到:只要不显式设置 Accept-Encoding,net/http 会自己带上 gzip
// 并透明解压。但书源 JSON 的 header 字段、或某些 CDN 的固定策略,都可能让响应
// 带着 Content-Encoding 回来,压缩字节一旦进入规则层,书源的 JSON.parse 就会
// 报 `invalid character '\x1f'`。这里做一次兜底,保证调用方拿到明文。
//
// brotli(br)无法在纯 Go 标准库里解,因此上面不再 advertise br;真遇到 br
// 响应则原样返回,由调用方按失败处理。
func DecompressBody(resp *http.Response, data []byte) []byte {
if resp == nil || len(data) == 0 {
return data
}
switch strings.ToLower(strings.TrimSpace(resp.Header.Get("Content-Encoding"))) {
case "gzip", "x-gzip":
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return data
}
defer r.Close()
// 不吞掉多个 member:默认 Multistream(true) 会让串接的 member 继续解,
// 是压缩炸弹的常见放大手段。
r.Multistream(false)
if out, ok := readLimited(r); ok {
return out
}
case "deflate":
// deflate 有两种实际写法:zlib 包装与裸 DEFLATE,依次尝试。
if out, ok := readLimited(flate.NewReader(bytes.NewReader(data))); ok {
return out
}
if zr, err := zlib.NewReader(bytes.NewReader(data)); err == nil {
defer zr.Close()
if out, ok := readLimited(zr); ok {
return out
}
}
}
return data
}
// readLimited 读取解压流,超过上限返回 ok=false。
func readLimited(r io.Reader) ([]byte, bool) {
out, err := io.ReadAll(io.LimitReader(r, maxDecompressedBody+1))
if err != nil || len(out) > maxDecompressedBody {
return nil, false
}
return out, true
}
// StripAcceptEncoding 移除显式设置的 Accept-Encoding,交回 net/http 管理。
//
// 只有「调用方没设置」时 net/http 才会自动解压,因此任何来源(预设头、书源
// header 字段)带来的 Accept-Encoding 都必须清掉。
func StripAcceptEncoding(h http.Header) {
h.Del("Accept-Encoding")
}
// NewSiteHTTPClient builds an http.Client honoring per-site policies:
// - timeout (seconds, defaults to 15)
// - proxy via HTTP(S)_PROXY environment variables when site.UseProxy is on
@@ -41,12 +112,31 @@ func NewSiteHTTPClient(timeoutSeconds int, useProxy bool) *http.Client {
// HTTPHeaderPresets returns a map of realistic browser HTTP headers.
// These mimic a real Chrome browser to avoid WAF/bot detection.
//
// 注意:这里刻意不设置 Accept-Encoding。
//
// net/http 只在「调用方没有显式设置 Accept-Encoding」时才会自己补上 gzip 并
// 透明解压;一旦我们显式写了这个头,它就原样把压缩字节交出来。之前这里写了
// "gzip, deflate, br",于是所有经历了压缩的响应(静态 JSON、CDN 上的页面等)
// 都会以原始压缩字节进入规则层,书源的 JSON.parse 直接报
// `SyntaxError: invalid character '\x1f'`,表现为「获取最新配置失败」。
//
// 交给 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",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
+85
View File
@@ -0,0 +1,85 @@
package helper
import (
"bytes"
"compress/gzip"
"net/http"
"testing"
)
// 本文件:压缩相关的回归测试。
//
// 背景:预设头里曾显式写 `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) {
for k := range HTTPHeaderPresets() {
if http.CanonicalHeaderKey(k) == "Accept-Encoding" {
t.Fatal("HTTPHeaderPresets 不应设置 Accept-Encoding(会让 net/http 放弃自动解压)")
}
}
}
// TestStripAcceptEncoding 任何来源带来的 Accept-Encoding 都应被清掉。
func TestStripAcceptEncoding(t *testing.T) {
h := http.Header{}
h.Set("Accept-Encoding", "br, gzip")
StripAcceptEncoding(h)
if got := h.Get("Accept-Encoding"); got != "" {
t.Fatalf("Accept-Encoding 未清理: %q", got)
}
}
// TestDecompressBody gzip / deflate 响应体应被还原成明文。
func TestDecompressBody(t *testing.T) {
payload := `{"version":"20260926"}`
t.Run("gzip", func(t *testing.T) {
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
_, _ = gz.Write([]byte(payload))
_ = gz.Close()
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("Content-Encoding", "gzip")
if got := string(DecompressBody(resp, buf.Bytes())); got != payload {
t.Fatalf("gzip 解压 = %q", got)
}
})
t.Run("未压缩原样返回", func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
if got := string(DecompressBody(resp, []byte(payload))); got != payload {
t.Fatalf("未压缩响应被改动: %q", got)
}
})
t.Run("坏数据不 panic", func(t *testing.T) {
resp := &http.Response{Header: http.Header{}}
resp.Header.Set("Content-Encoding", "gzip")
raw := []byte("not-gzip")
if got := string(DecompressBody(resp, raw)); got != "not-gzip" {
t.Fatalf("解压失败时应原样返回,实际 %q", got)
}
})
t.Run("nil resp", func(t *testing.T) {
if got := string(DecompressBody(nil, []byte(payload))); got != payload {
t.Fatalf("nil resp 应原样返回,实际 %q", got)
}
})
}
+87
View File
@@ -0,0 +1,87 @@
// Package helper — AES-256-GCM at-rest secret encryption.
//
// Sensitive values (third-party API keys, service passwords, book-source
// login credentials) are stored in the database. Encrypting them keyed off
// a per-install secret means a stolen DB file alone is not enough to
// recover the plaintext.
//
// Format on disk: "enc:v1:" + base64(nonce || ciphertext || tag).
// Legacy plaintext rows (no prefix) round-trip unchanged so an upgraded
// install needs no migration step.
package helper
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"strings"
)
// EncPrefix tags ciphertext rows so they can be told apart from plaintext.
const EncPrefix = "enc:v1:"
// SecretCipher encrypts/decrypts strings with AES-256-GCM derived from a
// stable per-install secret. A zero-value SecretCipher (empty key) is a
// pass-through, which keeps unit tests and keyless dev setups working.
type SecretCipher struct {
aead cipher.AEAD
}
// NewSecretCipher derives a 256-bit key from secret via SHA-256.
func NewSecretCipher(secret string) *SecretCipher {
if strings.TrimSpace(secret) == "" {
return &SecretCipher{}
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
return &SecretCipher{}
}
aead, err := cipher.NewGCM(block)
if err != nil {
return &SecretCipher{}
}
return &SecretCipher{aead: aead}
}
// Ready 表示是否真的能加解密(密钥有效)。
func (c *SecretCipher) Ready() bool { return c != nil && c.aead != nil }
// Encrypt returns the prefixed base64 ciphertext for plain.
// Empty inputs and already-encrypted values round-trip unchanged.
func (c *SecretCipher) Encrypt(plain string) string {
if plain == "" || !c.Ready() || strings.HasPrefix(plain, EncPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
sealed := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return EncPrefix + base64.StdEncoding.EncodeToString(sealed)
}
// Decrypt returns the plaintext for a stored value. Plaintext rows (no
// prefix) are returned unchanged, as are values that fail to authenticate.
func (c *SecretCipher) Decrypt(value string) string {
if value == "" || !c.Ready() || !strings.HasPrefix(value, EncPrefix) {
return value
}
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(value, EncPrefix))
if err != nil || len(data) < c.aead.NonceSize() {
return value
}
nonce, body := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, body, nil)
if err != nil {
return value
}
return string(plain)
}
// IsEncrypted reports whether value carries the encrypted prefix.
func (c *SecretCipher) IsEncrypted(value string) bool {
return strings.HasPrefix(value, EncPrefix)
}
+11 -7
View File
@@ -26,13 +26,17 @@ type LibraryRoot struct {
// Media 是单个可播放项。剧集链接到 SeriesID;电影 SeriesID == ""。
type Media struct {
Base
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
Path string `gorm:"uniqueIndex;size:1024;not null" json:"path"`
LibraryID string `gorm:"index;size:36;index:idx_media_library_release,priority:1" json:"library_id"`
LibraryRootID string `gorm:"index;size:36" json:"library_root_id,omitempty"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Title string `gorm:"size:255;not null" json:"title"`
OriginalName string `gorm:"size:255" json:"original_name,omitempty"`
EpisodeTitle string `gorm:"size:255" json:"episode_title,omitempty"`
// Path 的唯一性由 database 包按方言显式建索引,这里刻意不用 uniqueIndex:
// PostgreSQL 的 btree 单条索引项上限 2704 字节,而 varchar(1024) 在中文路径
// 下可达 3072 字节,直接对整列建唯一索引会让长路径的扫描插入直接失败
// (SQLite 没有这个限制)。见 database.ensureMediaPathIndex。
Path string `gorm:"size:1024;not null" json:"path"`
RelativePath string `gorm:"size:1024" json:"relative_path,omitempty"`
SizeBytes int64 `json:"size_bytes"`
DurationSec int `json:"duration_sec"`
+8
View File
@@ -61,5 +61,13 @@ func AllModels() []interface{} {
&StrmDirCache{},
&ScrapeTask{},
&EmbyMount{},
&ReaderBookSource{},
&ReaderSourceState{},
&ReaderBook{},
&ReaderChapter{},
&ReaderContentCache{},
&ReaderReplaceRule{},
&ReaderProfile{},
&ReaderBookGroups{},
}
}
+191
View File
@@ -0,0 +1,191 @@
// Package model — 阅读(legado 书源兼容)子系统数据模型。
// 字段语义对齐 legado 的 BookSource / Book / BookChapter / ReplaceRule 实体;
// 阅读进度沿用 legado 的做法直接挂在书籍上(durChapter* 字段)。
package model
import (
"time"
)
// ReaderBookSource 书源:原始 JSON 全量存储 + 常用字段冗余列出便于筛选排序。
//
// 书源按用户独立:同一份书源不同用户可以各存一份(各自启停、排序、修改规则与变量),
// 互不可见。唯一键是 (user_id, source_url);跨用户的「同一本书」靠
// ReaderBook.Origin(书源 URL)而不是书源行 ID 关联。
type ReaderBookSource struct {
Base
// UserID 归属用户。历史数据迁移时会回填;值为空表示「旧版全局书源」,
// 只在没有任何用户引用时才会出现。
UserID string `gorm:"type:varchar(36);uniqueIndex:uniq_reader_source_user_url;index" json:"user_id"`
Name string `gorm:"type:varchar(255);index" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Type int `gorm:"default:0" json:"type"` // 0文本 1音频 2图片 3文件 4视频
SourceURL string `gorm:"type:varchar(512);uniqueIndex:uniq_reader_source_user_url;index" json:"source_url"`
RawJSON string `gorm:"type:text" json:"-"`
Enabled bool `gorm:"default:true" json:"enabled"`
EnabledExplore bool `gorm:"default:true" json:"enabled_explore"`
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 存储的
// sourceVariable / userInfo(登录信息)/ loginHeader 与 CookieStore。
// 与书源分表存放,避免每次导入更新书源时把用户登录态覆盖掉。
type ReaderSourceState struct {
Base
SourceURL string `gorm:"type:varchar(512);uniqueIndex" json:"source_url"`
Variable string `gorm:"type:text" json:"variable"` // source.getVariable/setVariable
LoginInfo string `gorm:"type:text" json:"login_info"` // source.getLoginInfo/putLoginInfo(登录表单 JSON)
LoginHeader string `gorm:"type:text" json:"login_header"` // source 登录请求头 JSON
Cookies string `gorm:"type:text" json:"cookies"` // JSON: domain → "k=v; k=v"
}
// ReaderBook 书架条目(含阅读进度,对应 legado Book)。
type ReaderBook struct {
Base
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
Origin string `gorm:"type:varchar(512)" json:"origin"` // 书源 URL
OriginName string `gorm:"type:varchar(255)" json:"origin_name"`
BookURL string `gorm:"type:varchar(512);index" json:"book_url"`
TocURL string `gorm:"type:varchar(512)" json:"toc_url"`
Name string `gorm:"type:varchar(255)" json:"name"`
Author string `gorm:"type:varchar(255)" json:"author"`
Kind string `gorm:"type:varchar(255)" json:"kind"`
CoverURL string `gorm:"type:varchar(512)" json:"cover_url"`
Intro string `gorm:"type:text" json:"intro"`
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"`
// 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 判断。
LocalPath string `gorm:"type:varchar(255)" json:"-"`
// LocalExternal 为真表示原地引用服务器上已有的文件/目录(管理员在导入时选定),
// 移出书架只解除引用,不删除源文件;为假表示 data/reader/local 下的托管副本。
LocalExternal bool `json:"local_external"`
// IsLocal 是否本地导入书籍,列表接口按需计算,不落库。
IsLocal bool `gorm:"-" json:"is_local"`
// 听书(音频源)跳过片头/片尾秒数,对应 legado Book.getOpenCredits/getCloseCredits。
// 0 表示不跳过。仅对音频和视频源生效。
OpenCredits int `gorm:"default:0" json:"open_credits"`
CloseCredits int `gorm:"default:0" json:"close_credits"`
}
// ReaderChapter 章节缓存(对应 legado BookChapter)。
type ReaderChapter struct {
Base
BookID string `gorm:"type:varchar(36);uniqueIndex:idx_reader_book_chapter" json:"book_id"`
Index int `gorm:"uniqueIndex:idx_reader_book_chapter" json:"index"`
URL string `gorm:"type:varchar(512)" json:"url"`
Title string `gorm:"type:varchar(512)" json:"title"`
IsVolume bool `json:"is_volume"`
Tag string `gorm:"type:varchar(255)" json:"tag"`
}
// ReaderContentCache 正文持久缓存的索引行(内容本体在磁盘上,见 reader_content_cache.go)。
//
// 与 legado BookHelp 的章节正文缓存对应:缓存的是「书源侧产物」(书源 replaceRegex
// 之后、用户替换规则与代理改写之前),因此可以跨用户共享;用户维度的处理在读出后
// 逐请求应用,规则改动即时生效。
//
// 章节身份不落库为外键,而是 BookKey(书源 + 书本地址)与 ChapterKey(绝对化章节
// 地址或标题)的哈希:目录刷新(ReplaceChapters 物理重建、行 ID 会变)与书源更新
// 之后仍然能按同一身份命中或迁移。
type ReaderContentCache struct {
Base
// OriginHash 书源地址哈希(磁盘目录的第一层,清理时定位文件用)。
OriginHash string `gorm:"type:varchar(64)" json:"origin_hash"`
// BookKey 书源身份哈希(sha256(origin + "\0" + bookURL) 前 16 字节 hex)。
BookKey string `gorm:"type:varchar(64);index:idx_reader_content_book" json:"book_key"`
// ChapterKey 章节身份哈希(绝对化 URL 优先,退化为 title)。
ChapterKey string `gorm:"type:varchar(64);index:idx_reader_content_chapter" json:"chapter_key"`
// ChapterIdentity 章节身份原文(便于诊断与 remap 时的标题兜底匹配)。
ChapterIdentity string `gorm:"type:varchar(512)" json:"chapter_identity"`
// ChapterIndex 保存时的章节序号(remap 时更新)。
ChapterIndex int `json:"chapter_index"`
// ContentType text / audio / image。
ContentType string `gorm:"type:varchar(16)" json:"content_type"`
// SourceHash 书源内容指纹(RawJSON 哈希):书源更新后自然失效。
SourceHash string `gorm:"type:varchar(64)" json:"source_hash"`
// FormatVersion 缓存载荷格式版本:解析管线语义变化时递增,旧条目自然失效。
FormatVersion int `json:"format_version"`
// SizeBytes 载荷字节数(容量统计用)。
SizeBytes int64 `json:"size_bytes"`
// AssetCount 音频轨/图片张数(清单类内容的完整性统计)。
AssetCount int `json:"asset_count"`
// ExpiresAt 过期时间(unix 秒);0 表示不过期。
ExpiresAt int64 `json:"expires_at"`
// LastAccessAt 最近命中时间(unix 秒),LRU 淘汰依据。
LastAccessAt int64 `json:"last_access_at"`
// Hits 命中次数(诊断用)。
Hits int `json:"hits"`
}
// ReaderReplaceRule 替换净化规则(对应 legado ReplaceRule)。
type ReaderReplaceRule struct {
Base
UserID string `gorm:"type:varchar(36);index" json:"user_id"`
Name string `gorm:"type:varchar(255)" json:"name"`
GroupName string `gorm:"type:varchar(255);index" json:"group"`
Pattern string `gorm:"type:text" json:"pattern"`
Replacement string `gorm:"type:text" json:"replacement"`
Scope string `gorm:"type:varchar(255)" json:"scope"`
ScopeTitle bool `json:"scope_title"`
ScopeContent bool `gorm:"default:true" json:"scope_content"`
ExcludeScope string `gorm:"type:varchar(255)" json:"exclude_scope"`
IsEnabled bool `gorm:"default:true" json:"is_enabled"`
IsRegex bool `gorm:"default:true" json:"is_regex"`
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"`
}
+125
View File
@@ -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))
}
+84
View File
@@ -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)
}
}
+21
View File
@@ -0,0 +1,21 @@
package repository
import "gorm.io/gorm"
// LikeOperator 返回当前方言下「与 SQLite LIKE 行为一致」的模式匹配运算符。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写(默认编译选项),PostgreSQL 的 LIKE 区分
// 大小写、只有 ILIKE 才不区分。同一个搜索词在两种方言下必须给出同样的结果集,
// 否则换库之后「inception」就再也搜不到「Inception」。
//
// 供构造原始 SQL 片段的调用方(含 service 层)使用。
func LikeOperator(db *gorm.DB) string {
return likeOperator(db)
}
func likeOperator(db *gorm.DB) string {
if db != nil && db.Dialector != nil && db.Dialector.Name() == "postgres" {
return "ILIKE"
}
return "LIKE"
}
+54
View File
@@ -0,0 +1,54 @@
package repository
import (
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSegmentPrewarmQueryBindsBooleanFound 守住 PostgreSQL 下片段预取查询的合法性。
//
// media_segment_fetches.found 是布尔列,而 PostgreSQL 没有 boolean = integer
// 运算符:写成 f.found = 0 / = 1 会让计划任务里的片段预取固定报
//
// operator does not exist: boolean = integer (SQLSTATE 42883)
//
// SQLite 一直能跑,所以这个错只在 PostgreSQL 上暴露(表现为日志里周期性出现
// 该错误、片头片尾数据永远补不上)。这里断言语句里只能出现占位符绑定。
func TestSegmentPrewarmQueryBindsBooleanFound(t *testing.T) {
query := segmentPrewarmCandidatesQuery
lower := strings.ToLower(query)
for _, forbidden := range []string{"found = 0", "found = 1", "found=0", "found=1", "found = true", "found = false"} {
if strings.Contains(lower, forbidden) {
t.Fatalf("预取查询把布尔列与字面量比较(%q),PostgreSQL 会直接报错:\n%s", forbidden, query)
}
}
if got := strings.Count(query, "f.found = ?"); got != 2 {
t.Fatalf("expected 2 placeholder-bound found comparisons, got %d:\n%s", got, query)
}
}
// TestLikeOperatorMatchesSQLiteCaseInsensitivity 守住搜索大小写行为的方言差异。
//
// SQLite 的 LIKE 对 ASCII 不区分大小写,PostgreSQL 的 LIKE 区分大小写:同一个
// 搜索词在两种方言下必须给出同样的结果集,PostgreSQL 必须走 ILIKE。
func TestLikeOperatorMatchesSQLiteCaseInsensitivity(t *testing.T) {
pg, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
if got := LikeOperator(pg); got != "ILIKE" {
t.Fatalf("postgres LikeOperator = %q, want ILIKE", got)
}
if got := LikeOperator(nil); got != "LIKE" {
t.Fatalf("nil LikeOperator = %q, want LIKE", got)
}
}
+17 -3
View File
@@ -33,6 +33,20 @@ func (r *HistoryRepository) UpsertProgress(ctx context.Context, h *model.Playbac
return r.upsert(ctx, h, true)
}
// historyColumn 生成「当前表」限定的列引用。
//
// PostgreSQL 的 ON CONFLICT DO UPDATE ... WHERE 里,未限定的列名会同时可解析到
// 目标表和本次插入的行,于是报
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 结果是 POST /api/history 一律 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查,所以这个错只在 PostgreSQL 上暴露。clause.CurrentTable 由 GORM 在构建语句
// 时替换成真实表名,两种方言都能正确渲染。
func historyColumn(name string) clause.Column {
return clause.Column{Table: clause.CurrentTable, Name: name}
}
func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory, versioned bool) error {
updates := playbackHistoryAssignments(h)
if versioned {
@@ -49,16 +63,16 @@ func (r *HistoryRepository) upsert(ctx context.Context, h *model.PlaybackHistory
onConflict.Where = clause.Where{Exprs: []clause.Expression{
clause.Or(
clause.Lt{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.And(
clause.Eq{
Column: clause.Column{Name: "session_started_at_ms"},
Column: historyColumn("session_started_at_ms"),
Value: h.SessionStartedAtMs,
},
clause.Lte{
Column: clause.Column{Name: "sequence"},
Column: historyColumn("sequence"),
Value: h.Sequence,
},
),
@@ -0,0 +1,65 @@
package repository
import (
"context"
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestHistoryProgressUpsertQualifiesConflictWhereColumns 守住 PostgreSQL 下进度上报
// 的 upsert 语句合法性。
//
// ON CONFLICT DO UPDATE 的 WHERE 里如果不给列名加表限定,PostgreSQL 会因为这些列
// 同时出现在本次插入的行里而拒绝该语句:
//
// column reference "session_started_at_ms" is ambiguous (SQLSTATE 42702)
//
// 后果是 POST /api/history 全部 500,观看进度与续播完全存不下来。SQLite 不做这项
// 检查(旧写法在 SQLite 下一直正常),所以只能在 PostgreSQL 方言下断言。
func TestHistoryProgressUpsertQualifiesConflictWhereColumns(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
ctx := context.Background()
if err := repos.History.UpsertProgress(ctx, &model.PlaybackHistory{
UserID: "u-1",
MediaID: "m-1",
PositionMs: 1_000,
DurationMs: 2_000,
SessionID: "session-1",
SessionStartedAtMs: 111,
Sequence: 2,
}); err != nil {
t.Fatalf("build upsert: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from UpsertProgress")
}
if !strings.Contains(sql, "ON CONFLICT") {
t.Fatalf("expected an ON CONFLICT upsert, got:\n%s", sql)
}
// 只看 ON CONFLICT 之后的部分:SET 的赋值目标和 CASE 里的 duration_ms 引用
// 属于另一套语法规则,不在这里断言。
conflict := sql[strings.Index(sql, "ON CONFLICT"):]
whereIdx := strings.Index(conflict, " WHERE ")
if whereIdx < 0 {
t.Fatalf("ON CONFLICT clause has no DO UPDATE ... WHERE guard:\n%s", sql)
}
where := conflict[whereIdx:]
for _, want := range []string{
`"playback_histories"."session_started_at_ms"`,
`"playback_histories"."sequence"`,
} {
if !strings.Contains(where, want) {
t.Fatalf("DO UPDATE WHERE is missing the table-qualified %s; it is ambiguous on PostgreSQL:\n%s", want, sql)
}
}
if strings.Contains(where, `("session_started_at_ms"`) || strings.Contains(where, ` AND "sequence"`) {
t.Fatalf("DO UPDATE WHERE still references unqualified columns:\n%s", sql)
}
}
+49 -6
View File
@@ -115,7 +115,12 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return m.ID, nil
}
updates := mediaUpsertUpdates(existing, *m)
// 孤儿行回收:原库已被删除时(DeleteLibrary 硬删库行;扫描与删除并发时扫描也会
// 把行写回已删库),这条行会钉死在不存在的 library_id 上。media.path 是全局唯一
// 索引,重扫既插不进同路径新行,又因 LibraryID 非空而拒绝迁移归属,于是新库/重建库
// 永远查不到这些条目(表现为"扫描明明有文件,库却是空的")。这里显式识别并允许迁移。
reclaimLibrary := r.mediaRowLibraryIsOrphaned(ctx, db, existing.LibraryID, m.LibraryID, m.Path)
updates := mediaUpsertUpdates(existing, *m, reclaimLibrary)
if adopted {
updates["path"] = m.Path
if existing.DeletedAt.Valid {
@@ -139,6 +144,42 @@ func (r *MediaRepository) upsertWithDB(ctx context.Context, db *gorm.DB, m *mode
return existing.ID, nil
}
// mediaRowLibraryIsOrphaned reports whether a media row is still pinned to a
// library that no longer exists.
//
// 删除媒体库会硬删库行(见 MediaService.DeleteLibrary),但扫描与删除并发时,
// 已在途的扫描仍可能把这批文件写回已删库的 library_id;历史数据里也可能留下
// 指向早已消失的库的行。因为 media.path 是全局唯一索引,这些行一旦存在:
// 1. 重扫无法插入同路径的新行(唯一约束冲突);
// 2. addMediaPlacementUpdates 又因为 existing.LibraryID 非空而拒绝迁移归属。
//
// 结果就是新库 / 重建的同名库永远查不到这些条目,扫描报告有文件而库显示为空。
// 这里把"原库已不存在"这种情况单独判定出来,交给扫描把归属迁移到当前库。
func (r *MediaRepository) mediaRowLibraryIsOrphaned(ctx context.Context, db *gorm.DB, existingLibraryID, incomingLibraryID, incomingPath string) bool {
existingLibraryID = strings.TrimSpace(existingLibraryID)
incomingLibraryID = strings.TrimSpace(incomingLibraryID)
// 归属未变或原行为空(后者走既有的回填分支)时无需查库。
if existingLibraryID == "" || existingLibraryID == incomingLibraryID {
return false
}
// 云盘媒体的归属迁移由 addMediaPlacementUpdates 无条件处理,无需再查库。
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(incomingPath)), "cloud://") {
return false
}
if db == nil || !db.Migrator().HasTable(&model.Library{}) {
return false
}
var count int64
// 刻意不加 Unscoped:软删除的库同样不再出现在库里,挂在它下面的媒体行
// 也属于"查不到"的孤儿,一并允许回收。查询失败时保守处理:宁可保留旧
// 归属,也不要把仍有效的库误判成孤儿。
if err := db.WithContext(ctx).Model(&model.Library{}).
Where("id = ?", existingLibraryID).Count(&count).Error; err != nil {
return false
}
return count == 0
}
func (r *MediaRepository) findOrCreateMediaByPath(ctx context.Context, db *gorm.DB, m *model.Media, aliasPaths []string) (model.Media, bool, bool, error) {
var existing model.Media
err := db.WithContext(ctx).Unscoped().Where("path = ?", m.Path).First(&existing).Error
@@ -194,14 +235,14 @@ func (r *MediaRepository) findMediaByAlias(ctx context.Context, db *gorm.DB, cur
return existing, err
}
func mediaUpsertUpdates(existing, incoming model.Media) map[string]any {
func mediaUpsertUpdates(existing, incoming model.Media, reclaimLibrary bool) map[string]any {
updates := map[string]any{}
addMediaFileScanUpdates(updates, existing, incoming)
addMediaTitleUpdates(updates, existing, incoming)
addMediaExternalIDUpdates(updates, existing, incoming)
addMatchedMediaMetadataUpdates(updates, existing, incoming)
addMediaArtworkUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming)
addMediaPlacementUpdates(updates, existing, incoming, reclaimLibrary)
addMediaSTRMUpdate(updates, existing, incoming)
return updates
}
@@ -308,13 +349,15 @@ func addMediaArtworkUpdates(updates map[string]any, existing, incoming model.Med
}
}
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media) {
func addMediaPlacementUpdates(updates map[string]any, existing, incoming model.Media, reclaimLibrary bool) {
// 云盘媒体:同一 cloud:// 文件可能先被父目录库扫描入库,之后用户按二级
// 分类重新挂载/扫描到更精确的分类库。此时让 library_id 迁移到当前扫描库,
// 否则媒体被钉死在旧库、新分类库里看不到(表现为"媒体部分消失")。
// 本地媒体物理位置固定:仅在原 library_id 为空时回填,不迁移。
// 本地媒体物理位置固定:仅在原 library_id 为空、或原库已不存在(孤儿行,
// reclaimLibrary)时才回填/迁移,既避免把仍属于其他库的本地媒体抢走,
// 又能让扫描回收被删除库遗留的行。
if isCloudMediaPath := strings.HasPrefix(strings.ToLower(strings.TrimSpace(incoming.Path)), "cloud://"); incoming.LibraryID != "" && incoming.LibraryID != existing.LibraryID {
if isCloudMediaPath || existing.LibraryID == "" {
if isCloudMediaPath || existing.LibraryID == "" || reclaimLibrary {
updates["library_id"] = incoming.LibraryID
}
}
@@ -106,11 +106,8 @@ func (r *MediaRepository) searchFilteredLIKE(ctx context.Context, query string,
q := r.db.WithContext(ctx).Model(&model.Media{})
q = applyMediaQueryFilter(q, filter)
// SQLite 的 LIKE 对 ASCII 不区分大小写;Postgres 的 LIKE 区分大小写,
// 需用 ILIKE 保持两端搜索行为一致。
likeOp := "LIKE"
if r.db.Dialector != nil && r.db.Dialector.Name() == "postgres" {
likeOp = "ILIKE"
}
// 需用 ILIKE 保持两端搜索行为一致(见 dialect.go 的 likeOperator)。
likeOp := likeOperator(r.db)
terms := mediaSearchTerms(query)
for _, term := range terms {
like := "%" + escapeLike(term) + "%"
+30 -18
View File
@@ -86,21 +86,15 @@ func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.Med
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).Raw(`
// segmentPrewarmCandidatesQuery 是片段预取候选查询。
//
// 抽成包级常量是为了让方言相关的回归测试能直接断言语句文本:DryRun 模式下
// GORM 不会把 Raw 语句交给 logger,无法从执行路径上捕获生成后的 SQL。
//
// 注意 found 必须用占位符绑定布尔值,不能写 f.found = 0 / = 1:
// PostgreSQL 没有 boolean = integer 运算符,那样写会固定报 SQLSTATE 42883
// (SQLite 宽容,所以只在 PostgreSQL 上暴露)。
const segmentPrewarmCandidatesQuery = `
SELECT m.*
FROM media m
LEFT JOIN media_segment_fetches f
@@ -119,11 +113,29 @@ WHERE m.deleted_at IS NULL
)
AND (
f.id IS NULL
OR (f.found = 0 AND f.fetched_at < ?)
OR (f.found = 1 AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
OR (f.found = ? AND f.fetched_at < ?)
)
ORDER BY ph.last_played DESC
LIMIT ?
`, source, missBefore, hitBefore, limit).Scan(&rows).Error
`
// ListPrewarmCandidates returns media that should be refreshed from source:
// no ledger, expired miss (fetched_at < missBefore), or expired hit (fetched_at < hitBefore).
// Recently played items come first so hot titles recover coverage sooner.
func (r *MediaSegmentRepository) ListPrewarmCandidates(
ctx context.Context,
source string,
missBefore, hitBefore time.Time,
limit int,
) ([]model.Media, error) {
if r == nil || limit <= 0 {
return nil, nil
}
rows := make([]model.Media, 0, limit)
// 可查询:有 TMDb,且是剧集(有季集)或电影(无季集)。
err := r.db.WithContext(ctx).
Raw(segmentPrewarmCandidatesQuery, source, false, missBefore, true, hitBefore, limit).
Scan(&rows).Error
return rows, err
}
+482
View File
@@ -0,0 +1,482 @@
// Package repository — 阅读子系统仓储(书源 / 书架 / 章节 / 替换规则)。
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// ReaderRepository 阅读子系统 GORM 封装。
type ReaderRepository struct {
db *gorm.DB
}
// readerOrderBy 组装方言安全的 ORDER BY。
//
// 用 clause.Column 让 GORM 按当前方言给列名加引号(SQLite 反引号 / PostgreSQL 双引号),
// 而不是把引号硬编码进排序字符串。reader_books.order、reader_chapters.index、
// reader_replace_rules.order 都撞上了 SQL 关键字(order 在 PostgreSQL 里是保留字):
// 之前写成 .Order("`order` ASC"),SQLite 下正常,PostgreSQL 下反引号被词法分析成
// 运算符,直接报 `syntax error at or near "order"`,书架 / 目录 / 替换规则整页 500。
func readerOrderBy(columns ...clause.OrderByColumn) clause.OrderBy {
return clause.OrderBy{Columns: columns}
}
// ListSources 用户的书源列表(书源按用户独立,按 customOrder 排序)。
func (r *ReaderRepository) ListSources(ctx context.Context, userID string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order("custom_order ASC, updated_at DESC").Find(&out).Error
return out, err
}
// GetSource 按 ID 取书源。
func (r *ReaderRepository) GetSource(ctx context.Context, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceForUser 按 ID 取书源并校验归属用户。
func (r *ReaderRepository) GetSourceForUser(ctx context.Context, userID, id string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
if err := r.db.WithContext(ctx).First(&out, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return nil, err
}
return &out, nil
}
// GetSourceByURL 按用户 + 书源 URL 取书源(导入去重用)。
func (r *ReaderRepository) GetSourceByURL(ctx context.Context, userID, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND source_url = ?", userID, sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetSourceAnyByURL 按书源 URL 取任意一份副本(不限定用户)。
//
// 媒体代理、目录刷新等路径只拿得到书籍的 origin(书源 URL)而没有用户上下文;
// 请求目标由 URL 决定,任一副本都够用。多个用户各自改过规则/header 时,
// 取到哪一份只影响「用谁的默认 header」,不影响书籍本身的定位。
func (r *ReaderRepository) GetSourceAnyByURL(ctx context.Context, sourceURL string) (*model.ReaderBookSource, error) {
var out model.ReaderBookSource
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// ListSourceRowsByURL 取某书源 URL 的全部副本(判断是否仍有其他用户引用)。
func (r *ReaderRepository) ListSourceRowsByURL(ctx context.Context, sourceURL string) ([]model.ReaderBookSource, error) {
var out []model.ReaderBookSource
err := r.db.WithContext(ctx).Where("source_url = ?", sourceURL).Find(&out).Error
return out, err
}
// CreateSource 新增书源。
func (r *ReaderRepository) CreateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Create(src).Error
}
// UpdateSource 更新书源字段。
func (r *ReaderRepository) UpdateSource(ctx context.Context, src *model.ReaderBookSource) error {
return r.db.WithContext(ctx).Save(src).Error
}
// DeleteSource 删除书源(连带清理其会话状态)。
//
// 用物理删除而不是软删:唯一键是 (user_id, source_url),而索引会覆盖软删行——
// 软删后再导入同一书源会撞唯一约束(表现为「导入失败」),删掉再导入是本模块的
// 正常操作。书源本身是可重新导入的数据,不需要软删保留。
//
// 会话状态表仍按 source_url 一源一条(跨用户共享),因此只有在该 URL 已无任何
// 其他用户的书源副本时才连带清理,否则会把别人的登录态一起删掉。
func (r *ReaderRepository) DeleteSource(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
src := &model.ReaderBookSource{}
if err := tx.First(src, "id = ?", id).Error; err == nil && src.SourceURL != "" {
var others int64
if err := tx.Model(&model.ReaderBookSource{}).
Where("source_url = ? AND id <> ?", src.SourceURL, id).Count(&others).Error; err != nil {
return err
}
if others == 0 {
// 会话状态是「一源一条」,而 source_url 上有覆盖软删行的唯一索引:
// 软删会让这一行继续占着 source_url,之后 SaveSourceState 的
// First(默认排除软删行)查不到、Create 就会撞唯一约束,
// 表现为「保存书源会话状态失败: UNIQUE constraint failed」,
// cookie / 登录态从此再也存不进去。这里必须硬删。
if err := tx.Unscoped().Delete(&model.ReaderSourceState{}, "source_url = ?", src.SourceURL).Error; err != nil {
return err
}
}
}
return tx.Unscoped().Delete(&model.ReaderBookSource{}, "id = ?", id).Error
})
}
// GetSourceState 取书源会话状态;不存在返回 (nil, nil)。
func (r *ReaderRepository) GetSourceState(ctx context.Context, sourceURL string) (*model.ReaderSourceState, error) {
var out model.ReaderSourceState
err := r.db.WithContext(ctx).First(&out, "source_url = ?", sourceURL).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &out, nil
}
// 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).Unscoped().First(&existing, "source_url = ?", st.SourceURL).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return r.db.WithContext(ctx).Create(st).Error
}
if err != nil {
return err
}
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
}
// ListBooks 用户书架(按 order 排序)。
func (r *ReaderRepository) ListBooks(ctx context.Context, userID string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}},
clause.OrderByColumn{Column: clause.Column{Name: "updated_at"}, Desc: true},
)).Find(&out).Error
return out, err
}
// GetBook 按 ID 取书。
func (r *ReaderRepository) GetBook(ctx context.Context, id string) (*model.ReaderBook, error) {
var out model.ReaderBook
if err := r.db.WithContext(ctx).First(&out, "id = ?", id).Error; err != nil {
return nil, err
}
return &out, nil
}
// FindBookByURL 按用户 + 书源 + 书 URL 查书(加书架去重)。
func (r *ReaderRepository) FindBookByURL(ctx context.Context, userID, origin, bookURL string) (*model.ReaderBook, error) {
var out model.ReaderBook
err := r.db.WithContext(ctx).First(&out, "user_id = ? AND origin = ? AND book_url = ?", userID, origin, bookURL).Error
if err != nil {
return nil, err
}
return &out, nil
}
// ListBooksByOriginAndURL 按书源 + 书本地址查所有用户的书架记录。
// 目录链路的 book.putVariable 需要写回变量,而目录抓取是跨用户共享的
// (同一本书可能被多个用户收藏),所以这里不带 userID 过滤。
func (r *ReaderRepository) ListBooksByOriginAndURL(ctx context.Context, origin, bookURL string) ([]model.ReaderBook, error) {
var out []model.ReaderBook
err := r.db.WithContext(ctx).Where("origin = ? AND book_url = ?", origin, bookURL).Find(&out).Error
return out, err
}
// CreateBook / UpdateBook / DeleteBook。
func (r *ReaderRepository) CreateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Create(b).Error
}
func (r *ReaderRepository) UpdateBook(ctx context.Context, b *model.ReaderBook) error {
return r.db.WithContext(ctx).Save(b).Error
}
func (r *ReaderRepository) DeleteBook(ctx context.Context, userID, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&model.ReaderBook{}, "id = ? AND user_id = ?", id, userID).Error; err != nil {
return err
}
return tx.Delete(&model.ReaderChapter{}, "book_id = ?", id).Error
})
}
// ReplaceChapters 覆盖式刷新章节列表。
func (r *ReaderRepository) ReplaceChapters(ctx context.Context, bookID string, chapters []model.ReaderChapter) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 章节是纯缓存(软删会留下行,撞上 (book_id, index) 唯一索引),这里物理删除
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", bookID).Error; err != nil {
return err
}
if len(chapters) == 0 {
return nil
}
return tx.CreateInBatches(chapters, 500).Error
})
}
// SwitchBookOrigin 换源:清空旧源章节与更新书籍信息在同一事务内完成。
// 分开提交时若第二步失败,会留下「仍指向旧源、但目录已清空」的中间状态。
func (r *ReaderRepository) SwitchBookOrigin(ctx context.Context, book *model.ReaderBook) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ReaderChapter{}, "book_id = ?", book.ID).Error; err != nil {
return err
}
return tx.Save(book).Error
})
}
// ListChapters 按序取章节。
func (r *ReaderRepository) ListChapters(ctx context.Context, bookID string) ([]model.ReaderChapter, error) {
var out []model.ReaderChapter
err := r.db.WithContext(ctx).Where("book_id = ?", bookID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "index"}},
)).Find(&out).Error
return out, err
}
// ─── 正文持久缓存索引 ──────────────────────────────────────────────────────
// GetContentCache 按 (bookKey, chapterKey, contentType) 取缓存索引行。
func (r *ReaderRepository) GetContentCache(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
var out model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("book_key = ? AND chapter_key = ? AND content_type = ?", bookKey, chapterKey, contentType).
First(&out).Error
if err != nil {
return nil, err
}
return &out, nil
}
// GetContentCacheByChapter 按 (bookKey, chapterKey) 取缓存索引行。
// contentType 为空表示不限类型(正文链路不需要预知类型);给出类型时精确匹配。
func (r *ReaderRepository) GetContentCacheByChapter(ctx context.Context, bookKey, chapterKey, contentType string) (*model.ReaderContentCache, error) {
q := r.db.WithContext(ctx).Where("book_key = ? AND chapter_key = ?", bookKey, chapterKey)
if contentType != "" {
q = q.Where("content_type = ?", contentType)
}
var out model.ReaderContentCache
if err := q.Order("last_access_at DESC").First(&out).Error; err != nil {
return nil, err
}
return &out, nil
}
// UpsertContentCache 写入或更新缓存索引行(按 bookKey + chapterKey + contentType 去重)。
func (r *ReaderRepository) UpsertContentCache(ctx context.Context, row *model.ReaderContentCache) error {
existing, err := r.GetContentCache(ctx, row.BookKey, row.ChapterKey, row.ContentType)
if err == nil && existing != nil {
existing.ChapterIdentity = row.ChapterIdentity
existing.ChapterIndex = row.ChapterIndex
existing.SourceHash = row.SourceHash
existing.FormatVersion = row.FormatVersion
existing.SizeBytes = row.SizeBytes
existing.AssetCount = row.AssetCount
existing.ExpiresAt = row.ExpiresAt
existing.LastAccessAt = row.LastAccessAt
return r.db.WithContext(ctx).Save(existing).Error
}
return r.db.WithContext(ctx).Create(row).Error
}
// TouchContentCache 记录一次命中(更新命中时间与次数)。
func (r *ReaderRepository) TouchContentCache(ctx context.Context, id string, hits int, lastAccess int64) error {
return r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Where("id = ?", id).
Updates(map[string]any{"last_access_at": lastAccess, "hits": hits}).Error
}
// ListContentCacheByBook 列出某本书的全部缓存索引(目录刷新后的 remap 用)。
func (r *ReaderRepository) ListContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Find(&out).Error
return out, err
}
// DeleteContentCacheByBook 删除某本书的缓存索引,返回被删除的条目(调用方据此清理磁盘文件)。
func (r *ReaderRepository) DeleteContentCacheByBook(ctx context.Context, bookKey string) ([]model.ReaderContentCache, error) {
rows, err := r.ListContentCacheByBook(ctx, bookKey)
if err != nil || len(rows) == 0 {
return nil, err
}
if err := r.db.WithContext(ctx).Where("book_key = ?", bookKey).Delete(&model.ReaderContentCache{}).Error; err != nil {
return nil, err
}
return rows, nil
}
// DeleteContentCacheRow 删除单条缓存索引(remap 迁移旧键时用)。
func (r *ReaderRepository) DeleteContentCacheRow(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderContentCache{}, "id = ?", id).Error
}
// ListContentCacheExpired 按 TTL 取过期条目。
func (r *ReaderRepository) ListContentCacheExpired(ctx context.Context, now int64, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).
Where("expires_at > 0 AND expires_at < ?", now).
Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// ContentCacheStats 返回条目数与总字节数(容量淘汰用)。
func (r *ReaderRepository) ContentCacheStats(ctx context.Context) (int64, int64, error) {
var row struct {
Count int64
Bytes int64
}
err := r.db.WithContext(ctx).Model(&model.ReaderContentCache{}).
Select("COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes").Scan(&row).Error
return row.Count, row.Bytes, err
}
// ListContentCacheOldest 按最近命中时间取最旧的一批(LRU 淘汰用)。
func (r *ReaderRepository) ListContentCacheOldest(ctx context.Context, limit int) ([]model.ReaderContentCache, error) {
var out []model.ReaderContentCache
err := r.db.WithContext(ctx).Order("last_access_at ASC").Limit(limit).Find(&out).Error
return out, err
}
// CountChaptersByBook 一次统计多本书已缓存的章节数(书架显示未读章数用,避免逐本查询)。
// 没有目录缓存的书籍不会出现在返回结果里。
func (r *ReaderRepository) CountChaptersByBook(ctx context.Context, bookIDs []string) (map[string]int, error) {
out := make(map[string]int, len(bookIDs))
if len(bookIDs) == 0 {
return out, nil
}
var rows []struct {
BookID string
Total int
}
err := r.db.WithContext(ctx).
Model(&model.ReaderChapter{}).
Select("book_id, COUNT(*) AS total").
Where("book_id IN ?", bookIDs).
Group("book_id").
Scan(&rows).Error
if err != nil {
return nil, err
}
for _, row := range rows {
out[row.BookID] = row.Total
}
return out, nil
}
// GetChapter 取指定章节。
//
// index 在 PostgreSQL 里是保留字,必须让 GORM 按方言加引号(SQLite 反引号 /
// PostgreSQL 双引号)。此前写成硬编码的 "book_id = ? AND `index` = ?",
// PostgreSQL 会把反引号词法分析成运算符并报 syntax error at or near "`"。
func (r *ReaderRepository) GetChapter(ctx context.Context, bookID string, index int) (*model.ReaderChapter, error) {
var out model.ReaderChapter
err := r.db.WithContext(ctx).
Where(clause.Eq{Column: clause.Column{Name: "book_id"}, Value: bookID}).
Where(clause.Eq{Column: clause.Column{Name: "index"}, Value: index}).
First(&out).Error
if err != nil {
return nil, err
}
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
err := r.db.WithContext(ctx).Where("user_id = ?", userID).
Order(readerOrderBy(
clause.OrderByColumn{Column: clause.Column{Name: "order"}, Desc: true},
clause.OrderByColumn{Column: clause.Column{Name: "created_at"}},
)).Find(&out).Error
return out, err
}
// CreateReplaceRule / UpdateReplaceRule / DeleteReplaceRule。
func (r *ReaderRepository) CreateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
return r.db.WithContext(ctx).Create(rule).Error
}
func (r *ReaderRepository) UpdateReplaceRule(ctx context.Context, rule *model.ReaderReplaceRule) error {
return r.db.WithContext(ctx).Save(rule).Error
}
func (r *ReaderRepository) DeleteReplaceRule(ctx context.Context, userID, id string) error {
return r.db.WithContext(ctx).Delete(&model.ReaderReplaceRule{}, "id = ? AND user_id = ?", id, userID).Error
}
@@ -0,0 +1,116 @@
package repository
import (
"context"
"strings"
"sync"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
// sqlCaptureLogger 只记录 GORM 生成的 SQL,不打印任何日志。
type sqlCaptureLogger struct {
mu sync.Mutex
lines []string
}
func (l *sqlCaptureLogger) LogMode(gormlogger.LogLevel) gormlogger.Interface { return l }
func (l *sqlCaptureLogger) Info(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Warn(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Error(context.Context, string, ...interface{}) {}
func (l *sqlCaptureLogger) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
sql, _ := fc()
l.mu.Lock()
l.lines = append(l.lines, sql)
l.mu.Unlock()
}
func (l *sqlCaptureLogger) all() string {
l.mu.Lock()
defer l.mu.Unlock()
return strings.Join(l.lines, "\n")
}
// newPostgresDryRunRepos 返回使用 PostgreSQL 方言的 repository 容器:
// DryRun 只构建 SQL 不执行,DisableAutomaticPing 让它无需真实数据库也能离线运行,
// SkipDefaultTransaction 则挡掉 GORM 默认事务包装——那一步会真的去开事务连接,
// 在 DryRun 下同样会拨号(ToSQL 内部也是这么绕开的)。于是可以直接断言生成语句
// 在 PostgreSQL 下是否合法。
func newPostgresDryRunRepos(t *testing.T) (*Container, *sqlCaptureLogger) {
t.Helper()
rec := &sqlCaptureLogger{}
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: "postgres://mebox:mebox@127.0.0.1:5432/mebox?sslmode=disable",
}), &gorm.Config{
DryRun: true,
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
Logger: rec,
})
if err != nil {
t.Fatalf("open dry-run postgres: %v", err)
}
return New(db), rec
}
// TestReaderListsUseDialectQuotedReservedColumns 守住阅读子系统列表查询在
// PostgreSQL 下的合法性。reader_books.order / reader_chapters.index /
// reader_replace_rules.order 都是关键字级列名(order 在 PostgreSQL 里是保留字)。
//
// 排序条件曾经写成硬编码反引号的原始字符串,例如 .Order("`order` ASC"):
// SQLite 正常,PostgreSQL 会把反引号词法分析成运算符,直接报
// `syntax error at or near "order"` (SQLSTATE 42601),书架 / 目录 / 替换规则整页 500。
// 现在改用 clause.Column,由 GORM 按方言加引号(SQLite 反引号 / PostgreSQL 双引号)。
func TestReaderListsUseDialectQuotedReservedColumns(t *testing.T) {
ctx := context.Background()
cases := []struct {
name string
run func(repos *Container) error
wants []string
}{
{
name: "books",
run: func(repos *Container) error { _, err := repos.Reader.ListBooks(ctx, "user-1"); return err },
wants: []string{`"order"`, `"updated_at" DESC`},
},
{
name: "chapters",
run: func(repos *Container) error { _, err := repos.Reader.ListChapters(ctx, "book-1"); return err },
wants: []string{`"index"`},
},
{
name: "replace rules",
run: func(repos *Container) error { _, err := repos.Reader.ListReplaceRules(ctx, "user-1"); return err },
wants: []string{`"order" DESC`, `"created_at"`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
repos, rec := newPostgresDryRunRepos(t)
if err := tc.run(repos); err != nil {
t.Fatalf("build query: %v", err)
}
sql := rec.all()
if strings.TrimSpace(sql) == "" {
t.Fatal("no SQL captured from the repository call")
}
if strings.Contains(sql, "`") {
t.Fatalf("SQL uses backtick quoting, which PostgreSQL rejects:\n%s", sql)
}
if !strings.Contains(sql, "ORDER BY") {
t.Fatalf("SQL has no ORDER BY clause:\n%s", sql)
}
for _, want := range tc.wants {
if !strings.Contains(sql, want) {
t.Fatalf("SQL missing %q:\n%s", want, sql)
}
}
})
}
}
+207
View File
@@ -0,0 +1,207 @@
package repository
import (
"errors"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newReaderTestRepo(t *testing.T) *ReaderRepository {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(db).Reader
}
// TestSourceStateRoundTrip 验证会话状态按书源 URL 存取。
func TestSourceStateRoundTrip(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
// 不存在时返回 (nil, nil),调用方据此走默认值
got, err := repo.GetSourceState(ctx, "https://a.example.com")
if err != nil || got != nil {
t.Fatalf("首次读取应为空: got=%v err=%v", got, err)
}
st := &model.ReaderSourceState{
SourceURL: "https://a.example.com",
Variable: `{"线路":"v1"}`,
LoginInfo: `{"邮箱":"u@e.com"}`,
LoginHeader: `{"X-Token":"t"}`,
Cookies: `{"example.com":"qttoken=abc"}`,
}
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
got, err = repo.GetSourceState(ctx, "https://a.example.com")
if err != nil {
t.Fatal(err)
}
if got.LoginInfo != st.LoginInfo || got.Variable != st.Variable {
t.Fatalf("读回不一致: %+v", got)
}
// 再次保存应为更新而非插入(同 URL 唯一)
st.LoginInfo = `{"邮箱":"new@e.com"}`
if err := repo.SaveSourceState(ctx, st); err != nil {
t.Fatal(err)
}
var count int64
if err := repo.db.Model(&model.ReaderSourceState{}).
Where("source_url = ?", "https://a.example.com").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("同一书源应只有一行状态,实际 %d", count)
}
got, _ = repo.GetSourceState(ctx, "https://a.example.com")
if got.LoginInfo != `{"邮箱":"new@e.com"}` {
t.Fatalf("更新未生效: %+v", got)
}
}
// TestDeleteSourceAlsoClearsState 删除书源应连带清理登录态。
func TestDeleteSourceAlsoClearsState(t *testing.T) {
repo := newReaderTestRepo(t)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://b.example.com", Enabled: true}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://b.example.com", LoginInfo: `{"a":"b"}`,
}); err != nil {
t.Fatal(err)
}
if err := repo.DeleteSource(ctx, src.ID); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://b.example.com")
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal(err)
}
if st != nil {
t.Fatalf("书源删除后登录态应一并清理,实际仍在: %+v", st)
}
}
// 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)
ctx := t.Context()
src := &model.ReaderBookSource{Name: "源", SourceURL: "https://c.example.com", RawJSON: `{"v":1}`}
if err := repo.CreateSource(ctx, src); err != nil {
t.Fatal(err)
}
if err := repo.SaveSourceState(ctx, &model.ReaderSourceState{
SourceURL: "https://c.example.com", LoginInfo: `{"k":"keep"}`,
}); err != nil {
t.Fatal(err)
}
// 模拟导入更新:只改书源自身字段
src.RawJSON = `{"v":2}`
if err := repo.UpdateSource(ctx, src); err != nil {
t.Fatal(err)
}
st, err := repo.GetSourceState(ctx, "https://c.example.com")
if err != nil {
t.Fatal(err)
}
if st == nil || st.LoginInfo != `{"k":"keep"}` {
t.Fatalf("更新书源后登录态丢失: %+v", st)
}
}
+2
View File
@@ -36,6 +36,7 @@ type Container struct {
StrmDirCache *StrmDirCacheRepository
ScrapeTask *ScrapeTaskRepository
EmbyMount *EmbyMountRepository
Reader *ReaderRepository
}
// New 将每个 repository 连接到单个 *gorm.DB。
@@ -68,5 +69,6 @@ func New(db *gorm.DB) *Container {
StrmDirCache: &StrmDirCacheRepository{db: db},
ScrapeTask: &ScrapeTaskRepository{db: db},
EmbyMount: &EmbyMountRepository{db: db},
Reader: &ReaderRepository{db: db},
}
}
+64 -18
View File
@@ -1,9 +1,13 @@
// Package service — database hot backup and restore.
//
// BackupService uses SQLite's "VACUUM INTO" (available since 3.27.0) to
// produce a consistent copy of the database without blocking writers.
// Backup files are stored under {data_dir}/backups/ with a timestamp
// suffix. The admin UI can list / download / delete / restore them.
// Backup files are stored under {data_dir}/backups/ with a timestamp suffix.
// The admin UI can list / download / delete / restore them.
//
// 两种方言的备份形态不同:
// - SQLite:用 "VACUUM INTO"(3.27.0 起可用)产出一个一致的库文件,
// 扩展名 .db;恢复即「替换库文件 + 重启」。
// - PostgreSQL:没有单文件语义,用逻辑备份(见 backup_postgres.go),
// 扩展名 .json.gz;恢复是在单个事务内重建全部表。
package service
import (
@@ -65,18 +69,34 @@ func (b *BackupService) backupFilePath(filename string) (string, error) {
return path, nil
}
// Create produces a new hot backup via "VACUUM INTO".
// isPostgres reports whether the service is backed by PostgreSQL.
//
// PostgreSQL 不支持 SQLite 的 "VACUUM INTO",按文件替换恢复也会写错目标,
// 因此备份/恢复必须按方言分支,不能共用一条路径。
func (b *BackupService) isPostgres() bool {
return b.db != nil && b.db.Dialector != nil && b.db.Dialector.Name() == "postgres"
}
// Create produces a new hot backup. SQLite uses "VACUUM INTO"; PostgreSQL uses
// a logical dump.
func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
dir := b.backupDir()
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, err
}
ts := time.Now().Format("20060102_150405")
dst := filepath.Join(dir, fmt.Sprintf("mebox_%s.db", ts))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return nil, fmt.Errorf("VACUUM INTO failed: %w", err)
var (
dst string
err error
)
if b.isPostgres() {
dst, err = b.createPostgresBackup(ctx, ts)
} else {
dst, err = b.createSQLiteBackup(ctx, ts)
}
if err != nil {
return nil, err
}
stat, err := os.Stat(dst)
if err != nil {
@@ -91,6 +111,15 @@ func (b *BackupService) Create(ctx context.Context) (*BackupInfo, error) {
}, nil
}
func (b *BackupService) createSQLiteBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), fmt.Sprintf("mebox_%s.db", stamp))
// VACUUM INTO creates a clean, non-WAL copy atomically.
if err := b.db.WithContext(ctx).Exec("VACUUM INTO ?", dst).Error; err != nil {
return "", fmt.Errorf("VACUUM INTO failed: %w", err)
}
return dst, nil
}
// List returns every backup file in the backup directory, newest first.
func (b *BackupService) List() ([]BackupInfo, error) {
dir := b.backupDir()
@@ -103,7 +132,7 @@ func (b *BackupService) List() ([]BackupInfo, error) {
}
out := make([]BackupInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") {
if e.IsDir() || !isBackupFilename(e.Name()) {
continue
}
info, err := e.Info()
@@ -132,9 +161,10 @@ func (b *BackupService) Delete(filename string) error {
return os.Remove(path)
}
// Restore copies a backup over the live database using VACUUM INTO in
// reverse. WARNING: this is destructive — the live DB will be replaced.
// Callers should shut down the server after this call.
// Restore replaces the live database with the given backup.
// WARNING: this is destructive. SQLite restores by swapping the database file
// (the caller should shut down the server afterwards); PostgreSQL restores by
// rebuilding every table inside a single transaction.
func (b *BackupService) Restore(ctx context.Context, filename string) error {
src, err := b.backupFilePath(filename)
if err != nil {
@@ -143,6 +173,19 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
if _, err := os.Stat(src); err != nil {
return fmt.Errorf("backup not found: %s", filename)
}
if b.isPostgres() {
if !strings.HasSuffix(filename, postgresBackupSuffix) {
return errors.New("该备份是 SQLite 文件备份,当前运行在 PostgreSQL 上,无法恢复")
}
return b.restorePostgresBackup(ctx, src)
}
if !strings.HasSuffix(filename, ".db") {
return errors.New("该备份是 PostgreSQL 逻辑备份,当前运行在 SQLite 上,无法恢复")
}
return b.restoreSQLiteBackup(filename, src)
}
func (b *BackupService) restoreSQLiteBackup(filename, src string) error {
dbPath, err := filepath.Abs(b.cfg.Database.DBPath)
if err != nil || dbPath == "" {
return errors.New("invalid database path")
@@ -171,7 +214,13 @@ func (b *BackupService) Restore(ctx context.Context, filename string) error {
return nil
}
// isValidBackupFilename rejects path traversal attempts and non-.db files.
// isBackupFilename reports whether name looks like a backup produced by either
// dialect (SQLite ".db" or PostgreSQL ".json.gz").
func isBackupFilename(name string) bool {
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, postgresBackupSuffix)
}
// isValidBackupFilename rejects path traversal attempts and non-backup files.
func isValidBackupFilename(name string) bool {
if name == "" {
return false
@@ -179,8 +228,5 @@ func isValidBackupFilename(name string) bool {
if strings.ContainsAny(name, "/\\") || strings.Contains(name, "..") {
return false
}
if !strings.HasSuffix(name, ".db") {
return false
}
return true
return isBackupFilename(name)
}
+279
View File
@@ -0,0 +1,279 @@
package service
import (
"compress/gzip"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"time"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"github.com/truewhile/MeBox/internal/model"
)
// PostgreSQL 逻辑备份 / 恢复。
//
// SQLite 的备份是「VACUUM INTO 出一个一致的库文件,恢复时替换文件」,而
// PostgreSQL 既没有 VACUUM INTO,数据也由服务端进程管理,没有可替换的单文件,
// 所以这里实现逻辑备份:把每张表按主键顺序分块序列化成 JSON Lines(gzip 压缩)
// 写入 {data_dir}/backups/。
//
// 分块序列化而不是整表 marshal:media 表在真实媒体库上可达几十万行、每行还带
// overview / path 等长文本,整表进内存会把小内存的 NAS 打爆。
//
// 恢复在单个事务内「先清空全部模型表、再逐块插入」:任何一步失败(包括备份文件
// 读到一半损坏)都整体回滚,不会留下半清空的库。因此恢复不需要重启服务。
const (
postgresBackupSuffix = ".json.gz"
postgresBackupFormat = "mebox-postgres-logical"
postgresBackupVersion = 1
backupDumpChunkSize = 1000
backupRestoreBatchSize = 500
)
type postgresBackupHeader struct {
Format string `json:"format"`
Version int `json:"version"`
CreatedAt time.Time `json:"created_at"`
}
// postgresBackupRow 是「列名 → JSON 值」的一行。
//
// 刻意逐列反射序列化,而不是直接 json.Marshal 模型结构体:模型里有大量带
// json:"-" 的列(Base.DeletedAt、User.AllowedLibraryIDs、APIConfig.APIKey、
// StrmAccount.Config、PlayProfile.PINHash、ReaderBookSource.RawJSON…),走结构体
// 自带的序列化会把这些列整列丢掉——软删除行会在恢复后「复活」,加密后的第三方
// 密钥与 PIN 会全部丢失。
type postgresBackupRow map[string]json.RawMessage
type postgresBackupChunk struct {
Table string `json:"table"`
Rows []postgresBackupRow `json:"rows"`
}
// parseModelSchema 返回模型对应的表名与 GORM schema(逐列反射所需)。
func parseModelSchema(db *gorm.DB, m any) (table string, sch *schema.Schema, err error) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", nil, err
}
return stmt.Schema.Table, stmt.Schema, nil
}
// postgresBackupModelSchemas 返回「表名 → 模型 schema / 结构体类型」,用于把备份
// 里的行还原成 GORM 能插入的具体类型。
func postgresBackupModelSchemas(db *gorm.DB) (map[string]*schema.Schema, map[string]reflect.Type, error) {
schemas := make(map[string]*schema.Schema, len(model.AllModels()))
types := make(map[string]reflect.Type, len(model.AllModels()))
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(db, m)
if err != nil {
return nil, nil, err
}
schemas[table] = sch
types[table] = reflect.TypeOf(m).Elem()
}
return schemas, types, nil
}
// createPostgresBackup 把全库导出成一个 gzip 压缩的 JSON Lines 文件。
func (b *BackupService) createPostgresBackup(ctx context.Context, stamp string) (string, error) {
dst := filepath.Join(b.backupDir(), "mebox_"+stamp+postgresBackupSuffix)
file, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
if err != nil {
return "", err
}
complete := false
defer func() {
_ = file.Close()
if !complete {
// 半截备份文件比没有备份更危险:留着会让人以为有可用的还原点。
_ = os.Remove(dst)
}
}()
zw := gzip.NewWriter(file)
enc := json.NewEncoder(zw)
if err := enc.Encode(postgresBackupHeader{
Format: postgresBackupFormat,
Version: postgresBackupVersion,
CreatedAt: time.Now(),
}); err != nil {
return "", fmt.Errorf("写入备份头失败: %w", err)
}
for _, m := range model.AllModels() {
table, sch, err := parseModelSchema(b.db, m)
if err != nil {
return "", err
}
if !b.db.Migrator().HasTable(table) {
continue
}
if err := b.dumpPostgresTable(ctx, m, sch, table, enc); err != nil {
return "", err
}
}
if err := zw.Close(); err != nil {
return "", fmt.Errorf("压缩备份失败: %w", err)
}
if err := file.Sync(); err != nil {
return "", fmt.Errorf("写入备份失败: %w", err)
}
complete = true
return dst, nil
}
// dumpPostgresTable 按主键分块导出一张表。FindInBatches 会自己按主键升序游标
// 翻页(WHERE pk > last),不会像 OFFSET 分页那样在大表上做 O(n²) 扫描。
func (b *BackupService) dumpPostgresTable(ctx context.Context, m any, sch *schema.Schema, table string, enc *json.Encoder) error {
// 软删除行也要备份:恢复后应还原成删除前的样子,而不是让它们「复活」。
slicePtr := reflect.New(reflect.SliceOf(reflect.TypeOf(m).Elem()))
err := b.db.WithContext(ctx).Unscoped().
FindInBatches(slicePtr.Interface(), backupDumpChunkSize, func(_ *gorm.DB, _ int) error {
rows, err := encodePostgresBackupRows(ctx, sch, slicePtr.Elem())
if err != nil {
return fmt.Errorf("编码表 %s 失败: %w", table, err)
}
return enc.Encode(postgresBackupChunk{Table: table, Rows: rows})
}).Error
if err != nil {
return fmt.Errorf("导出表 %s 失败: %w", table, err)
}
return nil
}
// backupPersistableFields 返回真正对应数据库列的字段。
//
// GORM 的 Schema.Fields 里还带着 gorm:"-" 的字段(例如 User.AllowedLibraryList、
// LibraryTagList),它们的 DBName 是空字符串。不过滤的话多个「列」会共用空键:
// 编码时互相覆盖,解码时把某个字段的值写到另一个字段上,报出
// 「cannot unmarshal number into Go value of type []string」这类与真实数据无关的错。
func backupPersistableFields(sch *schema.Schema) []*schema.Field {
out := make([]*schema.Field, 0, len(sch.Fields))
for _, field := range sch.Fields {
if field.DBName == "" {
continue
}
out = append(out, field)
}
return out
}
// encodePostgresBackupRows 逐列反射序列化一个批次。
func encodePostgresBackupRows(ctx context.Context, sch *schema.Schema, slice reflect.Value) ([]postgresBackupRow, error) {
fields := backupPersistableFields(sch)
rows := make([]postgresBackupRow, 0, slice.Len())
for i := 0; i < slice.Len(); i++ {
element := slice.Index(i)
row := make(postgresBackupRow, len(fields))
for _, field := range fields {
value := field.ReflectValueOf(ctx, element)
if !value.IsValid() {
continue
}
raw, err := json.Marshal(value.Interface())
if err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
row[field.DBName] = raw
}
rows = append(rows, row)
}
return rows, nil
}
// decodePostgresBackupRows 把「列名 → JSON 值」还原成可直接插入的具体切片。
func decodePostgresBackupRows(ctx context.Context, sch *schema.Schema, modelType reflect.Type, rows []postgresBackupRow) (any, error) {
fields := backupPersistableFields(sch)
slice := reflect.MakeSlice(reflect.SliceOf(modelType), len(rows), len(rows))
for i, row := range rows {
element := slice.Index(i)
for _, field := range fields {
raw, ok := row[field.DBName]
if !ok {
continue
}
holder := reflect.New(field.FieldType)
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
if err := field.Set(ctx, element, holder.Elem().Interface()); err != nil {
return nil, fmt.Errorf("列 %s: %w", field.DBName, err)
}
}
}
return slice.Interface(), nil
}
// restorePostgresBackup 用逻辑备份重建全库。
func (b *BackupService) restorePostgresBackup(ctx context.Context, src string) error {
file, err := os.Open(src)
if err != nil {
return err
}
defer file.Close()
zr, err := gzip.NewReader(file)
if err != nil {
return fmt.Errorf("备份文件不是有效的 gzip: %w", err)
}
defer zr.Close()
dec := json.NewDecoder(zr)
var header postgresBackupHeader
if err := dec.Decode(&header); err != nil {
return fmt.Errorf("备份头损坏: %w", err)
}
if header.Format != postgresBackupFormat || header.Version != postgresBackupVersion {
return fmt.Errorf("不支持的备份格式 %q (v%d)", header.Format, header.Version)
}
schemas, modelTypes, err := postgresBackupModelSchemas(b.db)
if err != nil {
return err
}
// 恢复必须在单个事务里完成:清空成功后中途失败若不回滚,库就空了。
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 逆序清空,保证 library_roots → libraries 这类外键先删子表。
models := model.AllModels()
for i := len(models) - 1; i >= 0; i-- {
m := models[i]
if !tx.Migrator().HasTable(m) {
continue
}
if err := tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Unscoped().Delete(m).Error; err != nil {
return fmt.Errorf("清空表失败: %w", err)
}
}
for {
var chunk postgresBackupChunk
decodeErr := dec.Decode(&chunk)
if errors.Is(decodeErr, io.EOF) {
return nil
}
if decodeErr != nil {
return fmt.Errorf("备份文件损坏: %w", decodeErr)
}
sch, ok := schemas[chunk.Table]
if !ok {
return fmt.Errorf("备份包含未知表 %s,可能来自不兼容的版本", chunk.Table)
}
if len(chunk.Rows) == 0 {
continue
}
rows, err := decodePostgresBackupRows(ctx, sch, modelTypes[chunk.Table], chunk.Rows)
if err != nil {
return fmt.Errorf("表 %s 解码失败: %w", chunk.Table, err)
}
if err := tx.Unscoped().CreateInBatches(rows, backupRestoreBatchSize).Error; err != nil {
return fmt.Errorf("恢复表 %s 失败: %w", chunk.Table, err)
}
}
})
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"compress/gzip"
"context"
"os"
"path/filepath"
"reflect"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
)
func newBackupTestService(t *testing.T) (*BackupService, *config.Config) {
t.Helper()
cfg := &config.Config{}
cfg.App.DataDir = t.TempDir()
db := newServiceTestDB(t)
svc := &BackupService{cfg: cfg, log: zap.NewNop(), db: db}
// Create 会先建目录;直接调各方言的 create* 助手时需要它已存在。
if err := os.MkdirAll(svc.backupDir(), 0o750); err != nil {
t.Fatal(err)
}
return svc, cfg
}
// TestPostgresLogicalBackupRoundTrip 验证「备份 → 改库 → 恢复」后整库回到备份时的
// 样子,包括软删除行与 json:"-" 的列。
//
// 逻辑备份/恢复本身是方言无关的 GORM 代码(PostgreSQL 专有的只有文件后缀与
// isPostgres() 分派),所以可以直接在 SQLite 上跑。
func TestPostgresLogicalBackupRoundTrip(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
db := svc.db
lib := model.Library{Base: model.Base{ID: "lib-1"}, Name: "Movies", Path: "/media/movies", Type: "movie", Enabled: true}
if err := db.Create(&lib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.LibraryRoot{Base: model.Base{ID: "root-1"}, LibraryID: lib.ID, Path: "/media/movies"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-keep"}, LibraryID: lib.ID, Title: "Keep", Path: "/media/movies/keep.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{Base: model.Base{ID: "m-gone"}, LibraryID: lib.ID, Title: "Gone", Path: "/media/movies/gone.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Delete(&model.Media{}, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Setting{Key: "k", Value: "v"}).Error; err != nil {
t.Fatal(err)
}
// 下面这些列都带 json:"-":走结构体自带的 JSON 序列化会被整列丢掉,
// 恢复之后用户权限、置顶库、加密后的密钥会一起消失。
user := model.User{
Base: model.Base{ID: "u-1"},
Username: "alice",
PasswordHash: "hash",
Role: "user",
AllowedLibraryIDs: `["lib-1"]`,
PinnedLibraryIDs: `["lib-1"]`,
HideAdult: true,
IsActive: true,
DanmakuEnabled: true,
PlayerVr360GuideSeen: true,
}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.APIConfig{Base: model.Base{ID: "cfg-1"}, Provider: "tmdb", APIKey: "ciphertext", Enabled: true}).Error; err != nil {
t.Fatal(err)
}
dump, err := svc.createPostgresBackup(ctx, "20260101_000000")
if err != nil {
t.Fatalf("createPostgresBackup: %v", err)
}
if dump == "" {
t.Fatal("createPostgresBackup returned an empty path")
}
// 破坏当前数据:加一条备份里没有的、硬删一条备份里有的。
if err := db.Create(&model.Media{Base: model.Base{ID: "m-extra"}, LibraryID: lib.ID, Title: "Extra", Path: "/media/movies/extra.mkv"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Unscoped().Delete(&model.Media{}, "id = ?", "m-keep").Error; err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, dump); err != nil {
t.Fatalf("restorePostgresBackup: %v", err)
}
var ids []string
if err := db.Unscoped().Model(&model.Media{}).Order("id asc").Pluck("id", &ids).Error; err != nil {
t.Fatal(err)
}
if want := []string{"m-gone", "m-keep"}; !reflect.DeepEqual(ids, want) {
t.Fatalf("media ids after restore = %v, want %v", ids, want)
}
// 软删除状态必须原样还原,而不是被「复活」。
var gone model.Media
if err := db.Unscoped().First(&gone, "id = ?", "m-gone").Error; err != nil {
t.Fatal(err)
}
if !gone.DeletedAt.Valid {
t.Fatal("soft-deleted media was revived by restore")
}
var live int64
if err := db.Model(&model.Media{}).Where("id = ?", "m-keep").Count(&live).Error; err != nil {
t.Fatal(err)
}
if live != 1 {
t.Fatalf("live media count = %d, want 1", live)
}
var value string
if err := db.Model(&model.Setting{}).Where("key = ?", "k").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "v" {
t.Fatalf("setting value = %q, want v", value)
}
var libCount int64
if err := db.Model(&model.Library{}).Count(&libCount).Error; err != nil {
t.Fatal(err)
}
if libCount != 1 {
t.Fatalf("library count = %d, want 1", libCount)
}
var rootCount int64
if err := db.Model(&model.LibraryRoot{}).Count(&rootCount).Error; err != nil {
t.Fatal(err)
}
if rootCount != 1 {
t.Fatalf("library root count = %d, want 1", rootCount)
}
// json:"-" 的列必须同样被备份:它们是权限与凭据,丢了比丢一条媒体严重得多。
var restoredUser model.User
if err := db.First(&restoredUser, "id = ?", "u-1").Error; err != nil {
t.Fatal(err)
}
if restoredUser.PasswordHash != "hash" {
t.Fatalf("password hash = %q, want hash", restoredUser.PasswordHash)
}
if restoredUser.AllowedLibraryIDs != `["lib-1"]` {
t.Fatalf(`allowed library ids = %q, want ["lib-1"]`, restoredUser.AllowedLibraryIDs)
}
if restoredUser.PinnedLibraryIDs != `["lib-1"]` {
t.Fatalf(`pinned library ids = %q, want ["lib-1"]`, restoredUser.PinnedLibraryIDs)
}
if !restoredUser.HideAdult || !restoredUser.IsActive || !restoredUser.DanmakuEnabled || !restoredUser.PlayerVr360GuideSeen {
t.Fatalf("boolean columns lost: %+v", restoredUser)
}
var restoredConfig model.APIConfig
if err := db.First(&restoredConfig, "id = ?", "cfg-1").Error; err != nil {
t.Fatal(err)
}
if restoredConfig.APIKey != "ciphertext" {
t.Fatalf("api key = %q, want ciphertext", restoredConfig.APIKey)
}
}
// TestRestorePostgresBackupKeepsDataOnCorruptFile 验证损坏的备份不会在回滚后留下
// 半清空的库:恢复是先清空再插入,必须整体在一个事务里,否则中途失败就把库清掉了。
func TestRestorePostgresBackupKeepsDataOnCorruptFile(t *testing.T) {
svc, _ := newBackupTestService(t)
ctx := context.Background()
if err := svc.db.Create(&model.Setting{Key: "keep", Value: "me"}).Error; err != nil {
t.Fatal(err)
}
corrupt := filepath.Join(t.TempDir(), "broken.json.gz")
file, err := os.Create(corrupt)
if err != nil {
t.Fatal(err)
}
zw := gzip.NewWriter(file)
// 头部完整、正文半截:解码在清空表之后才会失败。
if _, err := zw.Write([]byte(
`{"format":"mebox-postgres-logical","version":1,"created_at":"2026-01-01T00:00:00Z"}` + "\n" +
`{"table":"media","rows":[`)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
if err := svc.restorePostgresBackup(ctx, corrupt); err == nil {
t.Fatal("corrupt backup should fail to restore")
}
var value string
if err := svc.db.Model(&model.Setting{}).Where("key = ?", "keep").Select("value").Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "me" {
t.Fatalf("a failed restore wiped the database: settings keep = %q", value)
}
}
// TestRestoreRejectsBackupFromOtherDialect 验证备份文件与当前方言不匹配时给出明确
// 错误,而不是把 SQLite 文件写到 PostgreSQL 部署的库路径上(那是原实现的隐患)。
func TestRestoreRejectsBackupFromOtherDialect(t *testing.T) {
svc, _ := newBackupTestService(t)
pgStyle := filepath.Join(svc.backupDir(), "mebox_20260101_000000.json.gz")
if err := os.WriteFile(pgStyle, []byte("not-gzip"), 0o600); err != nil {
t.Fatal(err)
}
// SQLite 路径下不允许恢复 PostgreSQL 逻辑备份。
if err := svc.Restore(context.Background(), filepath.Base(pgStyle)); err == nil {
t.Fatal("SQLite restore accepted a PostgreSQL logical backup")
}
}
+52 -13
View File
@@ -2,6 +2,8 @@ package service
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -12,6 +14,8 @@ import (
"sync"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service/cloud115"
)
@@ -24,6 +28,10 @@ const (
cloud115HLSSessionTTL = 30 * time.Minute
cloud115HLSMaxSessions = 256
cloud115HLSMaxManifest = 8 << 20
// cloud115HLSMaxEntries 单个会话最多记住多少个上游地址。key 由上游地址
// 派生(见 cloud115HLSKeyForURL),所以条目数只随「实际出现过的地址」增长,
// 正常一部片子就是几千条;这个上限只是防御异常/恶意播放列表的兜底。
cloud115HLSMaxEntries = 20000
)
type cloud115HLSSession struct {
@@ -35,7 +43,17 @@ type cloud115HLSSession struct {
mu sync.Mutex
entries map[string]string
next int
}
// cloud115HLSKeyForURL 由上游地址派生稳定的分片 key。
//
// 早期实现用自增序号(e1、e2…)在每次重写播放列表时重新编号:同一分片每次
// 重写都会拿到新 key,entries 随重写次数线性膨胀(实测一个 762 段的列表反复
// 重写后 key 已经涨到 e2288…e3049),客户端拿到的分片编号也会无谓漂移。
// 改成地址哈希后,同一分片在任何一次重写里都是同一个 key。
func cloud115HLSKeyForURL(upstream string) string {
sum := sha256.Sum256([]byte(upstream))
return hex.EncodeToString(sum[:12])
}
// Cloud115HLSProxy 把 115 云端 HLS 转成 MeBox 同源 HLS。
@@ -128,16 +146,16 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
// Content-Type 缺失时用前 4KB 内容嗅探是否为播放列表;无论结果如何
// 都要把已读前缀接回 body,避免分片/播放列表丢开头字节。
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
copyUpstreamHeaders(w, resp, isPlaylist)
@@ -195,9 +213,17 @@ func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
// ExpiresAt 恒为「最近一次活跃时间 + TTL」(lookup 会滑动续期),
// 因此最小者即最久未活跃的会话,淘汰它对正在播放的影响最小。
evictID := ""
var evictAt time.Time
for id, existing := range p.sessions {
if evictID == "" || existing.ExpiresAt.Before(evictAt) {
evictID, evictAt = id, existing.ExpiresAt
}
}
if evictID != "" {
delete(p.sessions, evictID)
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
@@ -210,9 +236,15 @@ func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, s
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
if session != nil {
if time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
} else {
// 滑动续期:VOD 点播整个播放期间只有分片请求,master 只在起播时取
// 一次,不续期的话会话会在 TTL 后被删,长视频播放到一半必然断流。
session.ExpiresAt = time.Now().Add(cloud115HLSSessionTTL)
}
}
p.mu.Unlock()
if session == nil {
@@ -288,12 +320,19 @@ func (p *Cloud115HLSProxy) rewriteManifest(session *cloud115HLSSession, text, ba
}
func (p *Cloud115HLSProxy) proxyURL(session *cloud115HLSSession, upstream, rawQuery string) string {
key := cloud115HLSKeyForURL(upstream)
session.mu.Lock()
session.next++
key := "e" + strconv.Itoa(session.next)
session.entries[key] = upstream
_, known := session.entries[key]
if !known && len(session.entries) < cloud115HLSMaxEntries {
session.entries[key] = upstream
known = true
}
mediaID := session.MediaID
session.mu.Unlock()
if !known && p != nil && p.service != nil && p.service.log != nil {
p.service.log.Warn("cloud115 hls: session entry cap reached, segment will not resolve",
zap.String("session", session.ID), zap.Int("cap", cloud115HLSMaxEntries))
}
query := childProxyQuery(rawQuery, mediaID)
return "/api/cloud115/hls/" + url.PathEscape(session.ID) + "/" + url.PathEscape(key) + "?" + query
+92 -4
View File
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -24,7 +25,8 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
if strings.Contains(rewritten, "cpats01.115.com") {
t.Fatalf("upstream URL leaked into rewritten manifest: %s", rewritten)
}
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/e1?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/a.m3u8?u=1&se=2")
if !strings.Contains(rewritten, "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("proxy URL missing: %s", rewritten)
}
if !strings.Contains(rewritten, "media_id=media-1") || !strings.Contains(rewritten, "token=t") {
@@ -35,6 +37,32 @@ func TestCloud115HLSProxyRewriteManifest(t *testing.T) {
}
}
func TestCloud115HLSProxyRewriteIsStableAcrossRewrites(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{},
}
manifest := "#EXTM3U\n#EXT-X-TARGETDURATION:10\n" +
"https://cpats01.115.com/seg0.ts?x=0\nhttps://cpats01.115.com/seg1.ts?x=1\n"
first := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
entriesAfterFirst := len(session.entries)
second := proxy.rewriteManifest(session, manifest, "https://cpats01.115.com/v.m3u8", "media_id=media-1")
// 同一分片在每次重写里都必须是同一个 key,客户端拿到的地址才不会无谓漂移。
if first != second {
t.Fatalf("rewrite is not stable:\nfirst = %s\nsecond = %s", first, second)
}
if len(session.entries) != entriesAfterFirst {
t.Fatalf("entries grew on rewrite: %d -> %d", entriesAfterFirst, len(session.entries))
}
if entriesAfterFirst != 2 {
t.Fatalf("session entries = %d, want 2", entriesAfterFirst)
}
}
func TestCloud115HLSProxyRewriteKeyURI(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
@@ -56,6 +84,67 @@ https://cpats01.115.com/seg.ts?x=1
}
}
// 长视频 VOD 播放期间只有分片请求经过 lookup,会话必须随活动滑动续期,
// 否则固定 30 分钟 TTL 一到就会在中途 404 断流。
func TestCloud115HLSProxyLookupRenewsSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
}
proxy.storeSession(session)
// 模拟播放进行到第 29 分钟:距过期只剩 1 分钟。
session.ExpiresAt = time.Now().Add(time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); !ok {
t.Fatal("active session should still resolve before expiry")
}
if remaining := time.Until(session.ExpiresAt); remaining < cloud115HLSSessionTTL-10*time.Second {
t.Fatalf("session not renewed on activity, remaining = %v", remaining)
}
// 真正过期(期间无任何活动)的会话仍要被清理。
session.ExpiresAt = time.Now().Add(-time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); ok {
t.Fatal("expired session should not resolve")
}
proxy.mu.Lock()
_, exists := proxy.sessions[session.ID]
proxy.mu.Unlock()
if exists {
t.Fatal("expired session should be evicted from the map")
}
}
// 满员驱逐必须淘汰最久未活跃的会话(ExpiresAt = 最近活跃 + TTL,最小者即
// 最久未活跃),而不是随机挑一个——随机驱逐可能正好杀掉正在播放的会话。
func TestCloud115HLSProxyEvictsLeastRecentlyActiveSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
first := &cloud115HLSSession{ID: "active", MediaID: "media-1", entries: map[string]string{}}
proxy.storeSession(first)
for i := 0; i < cloud115HLSMaxSessions-1; i++ {
proxy.storeSession(&cloud115HLSSession{ID: fmt.Sprintf("s-%03d", i), MediaID: "media-1", entries: map[string]string{}})
}
// 人为拉开活跃时间差,避免依赖时钟精度:active 最近活跃,s-000 最久未活跃。
base := time.Now()
first.ExpiresAt = base.Add(cloud115HLSSessionTTL)
proxy.sessions["s-000"].ExpiresAt = base.Add(cloud115HLSSessionTTL - time.Hour)
proxy.storeSession(&cloud115HLSSession{ID: "newcomer", MediaID: "media-1", entries: map[string]string{}})
proxy.mu.Lock()
_, activeExists := proxy.sessions["active"]
_, staleExists := proxy.sessions["s-000"]
proxy.mu.Unlock()
if !activeExists {
t.Fatal("recently active session must survive eviction")
}
if staleExists {
t.Fatal("least recently active session should have been evicted")
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
@@ -89,7 +178,6 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
@@ -100,7 +188,8 @@ func TestCloud115HLSProxyServeChildRewritesVariant(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/e2?") {
wantKey := cloud115HLSKeyForURL("https://cpats01.115.com/seg.ts?x=1")
if !strings.Contains(rec.Body.String(), "/api/cloud115/hls/sess/"+wantKey+"?") {
t.Fatalf("rewritten variant missing proxy segment: %s", rec.Body.String())
}
if strings.Contains(rec.Body.String(), "cpats01.115.com") {
@@ -130,7 +219,6 @@ func TestCloud115HLSProxyServeChildStreamsRange(t *testing.T) {
MediaID: "media-1",
ExpiresAt: time.Now().Add(time.Hour),
entries: map[string]string{"e1": "https://cpats01.115.com/seg.ts?x=1"},
next: 1,
}
proxy.sessions[session.ID] = session
req := httptest.NewRequest(http.MethodGet, "/api/cloud115/hls/sess/e1?media_id=media-1&token=t", nil)
+12
View File
@@ -220,6 +220,11 @@ func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaI
return strings.TrimSpace(item.URL), data, nil
}
// cloud115PushStateRetention pushState 条目保留上限。成功条目 3 小时内用于
// 去重;超过一倍余量即为死数据,触发转码请求时顺带清理,防止 map 随观看过的
// 条目数无限增长。
const cloud115PushStateRetention = 6 * time.Hour
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
@@ -230,6 +235,13 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
now := time.Now()
s.mu.Lock()
if len(s.pushState) > 0 {
for k, v := range s.pushState {
if now.Sub(v.at) > cloud115PushStateRetention {
delete(s.pushState, k)
}
}
}
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
+15 -59
View File
@@ -12,96 +12,52 @@
package service
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"strings"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
)
// encPrefix tags ciphertext rows so we can tell them apart from legacy
// plaintext values.
const encPrefix = "enc:v1:"
// plaintext values. Kept as an alias of the shared helper's prefix so both
// implementations stay wire-compatible.
const encPrefix = helper.EncPrefix
// CryptoService wraps an AES-GCM cipher derived from a stable per-install
// secret (the JWT secret).
//
// The cipher itself lives in helper.SecretCipher so lower layers (e.g. the
// reader subsystem, which cannot import this package) can share one
// implementation; this type keeps the service-layer logging and API.
type CryptoService struct {
log *zap.Logger
aead cipher.AEAD
log *zap.Logger
cipher *helper.SecretCipher
}
// NewCryptoService derives a 256-bit key from the given secret via
// SHA-256 and constructs an AES-GCM AEAD. Empty secrets yield a service
// whose Encrypt/Decrypt methods are pass-throughs (used in unit tests).
func NewCryptoService(secret string, log *zap.Logger) *CryptoService {
c := &CryptoService{log: log}
if strings.TrimSpace(secret) == "" {
return c
}
sum := sha256.Sum256([]byte(secret))
block, err := aes.NewCipher(sum[:])
if err != nil {
log.Error("crypto: aes.NewCipher", zap.Error(err))
return c
}
aead, err := cipher.NewGCM(block)
if err != nil {
log.Error("crypto: cipher.NewGCM", zap.Error(err))
return c
}
c.aead = aead
return c
return &CryptoService{log: log, cipher: helper.NewSecretCipher(secret)}
}
// Encrypt returns the base64-encoded ciphertext (with prefix) for plain.
// Empty inputs round-trip unchanged.
func (c *CryptoService) Encrypt(plain string) string {
if plain == "" || c.aead == nil {
return plain
}
if strings.HasPrefix(plain, encPrefix) {
return plain
}
nonce := make([]byte, c.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return plain
}
cipherBytes := c.aead.Seal(nonce, nonce, []byte(plain), nil)
return encPrefix + base64.StdEncoding.EncodeToString(cipherBytes)
return c.cipher.Encrypt(plain)
}
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if c == nil || value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
return value
}
raw := strings.TrimPrefix(value, encPrefix)
data, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return value
}
if len(data) < c.aead.NonceSize() {
return value
}
nonce, cipherBytes := data[:c.aead.NonceSize()], data[c.aead.NonceSize():]
plain, err := c.aead.Open(nil, nonce, cipherBytes, nil)
if err != nil {
return value
}
return string(plain)
return c.cipher.Decrypt(value)
}
// IsEncrypted returns true if value carries the encrypted prefix.
func (c *CryptoService) IsEncrypted(value string) bool {
return strings.HasPrefix(value, encPrefix)
return c.cipher.IsEncrypted(value)
}
// MaskAPIKey returns "abcd****wxyz" so the key can be displayed in the
@@ -0,0 +1,82 @@
package service
import (
"strings"
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestLikelyEpisodicPathSQLNormalisesSeparator 守住剧集路径判定的转义差异。
//
// 此前 embyLikelyEpisodicPathSQL 会额外生成一份反斜杠模式
// (strings.ReplaceAll(pattern, "/", `\`))。PostgreSQL 的 LIKE 默认就把反斜杠
// 当转义符,模式结尾的 `\%` 被解析成「字面百分号」而不是「反斜杠 + 通配符」,
// 那些模式在 PostgreSQL 下永不命中;SQLite 没有默认转义符所以一直正常。
// 现在统一在 SQL 里把分隔符归一化成 '/',模式本身不再含反斜杠。
func TestLikelyEpisodicPathSQLNormalisesSeparator(t *testing.T) {
clause, args := embyLikelyEpisodicPathSQL()
if !strings.Contains(clause, "REPLACE(media.path") {
t.Fatalf("clause no longer normalises the path separator:\n%s", clause)
}
if got := strings.Count(clause, "LIKE ?"); got != len(args) {
t.Fatalf("clause has %d placeholders but %d args:\n%s", got, len(args), clause)
}
for _, arg := range args {
pattern, ok := arg.(string)
if !ok {
t.Fatalf("unexpected pattern type %T", arg)
}
if strings.Contains(pattern, `\`) {
t.Fatalf("pattern %q still contains a backslash; PostgreSQL's LIKE would treat it as an escape character", pattern)
}
if !strings.Contains(pattern, "/") {
t.Fatalf("pattern %q lost its separator", pattern)
}
}
}
// TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths 验证反斜杠路径(Windows
// 宿主机入库的媒体)仍然被判成剧集并从电影库查询里排除。
func TestFilterLikelyEpisodicPathsHandlesWindowsStylePaths(t *testing.T) {
db := newServiceTestDB(t)
episodic := model.Media{
Base: model.Base{ID: "ep-1"},
Path: `D:\Media\国产剧\我的剧\Season 1\我的剧 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
movie := model.Media{
Base: model.Base{ID: "mv-1"},
Path: `D:\Media\电影\某电影\某电影.mkv`,
}
if err := db.Create(&episodic).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&movie).Error; err != nil {
t.Fatal(err)
}
q := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0"),
)
var rows []model.Media
if err := q.Find(&rows).Error; err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("windows-style episodic path was not excluded, got %d rows", len(rows))
}
// 反证:电影类型的行必须保留。
var kept []model.Media
keepQ := filterLikelyEpisodicPathsFromMovieQuery(
db.Model(&model.Media{}).Where("path LIKE ?", `D:\Media\电影\%`),
)
if err := keepQ.Find(&kept).Error; err != nil {
t.Fatal(err)
}
if len(kept) != 1 || kept[0].ID != "mv-1" {
t.Fatalf("plain movie row was dropped from the movie query: %+v", kept)
}
}
@@ -0,0 +1,92 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// Emby/Jellyfin 对「不适用」的字段不下发(JSON 里没有该键),而不是下发空串。
// 小幻影视(RodelPlayer)2.2610 会把电影条目的 "SeasonId": "" 原样传进播放准备
// 逻辑并抛 ArgumentException(Parameter 'seasonId'),导致整个起播失败。
func TestEmbyMovieItemOmitsEmptySeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "小姐姐", Path: `/media/小姐姐`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
movie := model.Media{
Base: model.Base{ID: "movie-strm"},
LibraryID: lib.ID,
Title: "SNIS-919",
Path: `/media/小姐姐/三上悠亚/SNIS-919/SNIS-919.strm`,
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
if err := svc.repo.DB.Create(&movie).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &movie, false, 0, false)
if item["Type"] != "Movie" {
t.Fatalf("Type = %#v, want Movie", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
if value, ok := item[key]; ok {
t.Fatalf("%s must be omitted for a movie, got %#v", key, value)
}
}
}
// 剧集的季/剧归属是真实存在的,不能被一起删掉。
func TestEmbyEpisodeItemKeepsSeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
episode := model.Media{
Base: model.Base{ID: "ep-1"},
LibraryID: lib.ID,
Title: "命运石之门",
Path: `/media/tv/命运石之门/Season 01/命运石之门 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := svc.repo.DB.Create(&episode).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &episode, false, 0, false)
if item["Type"] != "Episode" {
t.Fatalf("Type = %#v, want Episode", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
value, _ := item[key].(string)
if value == "" {
t.Fatalf("%s = %#v, want a non-empty value for an episode", key, item[key])
}
}
}
func TestDropEmptyStringFields(t *testing.T) {
item := map[string]any{
"SeasonId": "",
"SeriesId": " ",
"SeriesName": "命运石之门",
"IndexNumber": 0,
}
dropEmptyStringFields(item, "SeasonId", "SeriesId", "SeriesName")
if _, ok := item["SeasonId"]; ok {
t.Fatalf("empty SeasonId must be removed")
}
if _, ok := item["SeriesId"]; ok {
t.Fatalf("whitespace-only SeriesId must be removed")
}
if item["SeriesName"] != "命运石之门" {
t.Fatalf("non-empty SeriesName must be kept, got %#v", item["SeriesName"])
}
if item["IndexNumber"] != 0 {
t.Fatalf("non-string fields must be left alone, got %#v", item["IndexNumber"])
}
}
+25
View File
@@ -787,9 +787,34 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
if includePeople {
item["People"] = e.resolveMediaPeople(ctx, m)
}
// Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键),而不是下发
// 空串或占位值。电影等非剧集条目没有季/剧归属,小幻影视(RodelPlayer)2.2610
// 会把 "SeasonId": "" 原样传进播放准备逻辑并抛 ArgumentException
// (Parameter 'seasonId'),起播直接失败;"SeasonName": "特别篇" 也会让客户端
// 把电影当成特别篇展示。剧集则保留真实的季/剧信息,只清掉空值。
seasonKeys := []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"}
if itemType == "Episode" {
dropEmptyStringFields(item, seasonKeys...)
} else {
for _, key := range seasonKeys {
delete(item, key)
}
}
return item
}
// dropEmptyStringFields 删除值为空串(或全空白)的字符串字段。
//
// 用于对齐 Emby/Jellyfin 的序列化行为:这些字段在没有值时是「不存在」,
// 而不是空串。客户端普遍把空串当合法值使用,收到空串会当成非法参数。
func dropEmptyStringFields(item map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) == "" {
delete(item, key)
}
}
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil {
return []map[string]any{}
+11 -2
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
@@ -21,7 +22,11 @@ func (e *EmbyService) mediaItems(ctx context.Context, p ItemsParams) (map[string
q = q.Where("library_id IN ? OR series_id = ?", e.mergedLibraryIDs(ctx, p.ParentID), p.ParentID)
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -264,7 +269,11 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin
q = q.Where("library_id IN ?", e.mergedLibraryIDs(ctx, libraryID))
}
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// ILIKE(PostgreSQL)/ LIKE(SQLite)由方言决定,避免换库后搜索词
// 大小写不匹配就搜不到结果。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
+17 -8
View File
@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// movieLibraryHasEpisodicContent 报告电影类型库里是否混入了「剧集结构」内容
@@ -51,7 +52,11 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map
q = e.applyUserMediaVisibility(ctx, q, p.UserID)
q = q.Where("library_id IN ?", libIDs)
if p.SearchTerm != "" {
q = q.Where("title LIKE ? OR original_name LIKE ?", "%"+p.SearchTerm+"%", "%"+p.SearchTerm+"%")
// 方言相关的 ILIKE/LIKE:PostgreSQL 的 LIKE 区分大小写,
// 直接用 LIKE 会让客户端搜索的大小写与入库标题不一致时搜不到。
likeOp := repository.LikeOperator(e.repo.DB)
term := "%" + p.SearchTerm + "%"
q = q.Where("title "+likeOp+" ? OR original_name "+likeOp+" ?", term, term)
}
if containsEmbyFilter(p.Filters, "IsFavorite") {
if strings.TrimSpace(p.UserID) == "" {
@@ -266,6 +271,14 @@ func filterLikelyEpisodicPathsFromMovieQuery(q *gorm.DB) *gorm.DB {
return q.Where("NOT ((media.season_num > 0 OR media.episode_num > 0) AND ("+clause+"))", args...)
}
// embyEpisodicPathExpr 把路径分隔符归一化成 '/' 后再小写化,供剧集路径判定使用。
//
// 反斜杠归一化必须放在 SQL 里做,而不是再生成一份反斜杠模式:PostgreSQL 的
// LIKE 默认就把反斜杠当转义符,模式结尾的 `\%` 会被解析成「字面百分号」而不是
// 「反斜杠 + 通配符」,那些模式在 PostgreSQL 下永不命中(SQLite 没有默认转义符,
// 所以只有换库之后才会暴露)。归一化列值后两种方言结果一致。
const embyEpisodicPathExpr = `LOWER(REPLACE(media.path, '\', '/'))`
func embyLikelyEpisodicPathSQL() (string, []any) {
patterns := []string{
"%/season %/%", "%/season.%/%", "%/season-%/%", "%/season_%/%",
@@ -274,15 +287,11 @@ func embyLikelyEpisodicPathSQL() (string, []any) {
"%/电视剧/%", "%/剧集/%", "%/连续剧/%", "%/短剧/%", "%/国产剧/%", "%/国剧/%", "%/大陆剧/%", "%/华语剧/%", "%/国产电视剧/%", "%/大陆电视剧/%", "%/华语电视剧/%", "%/欧美剧/%", "%/欧美电视剧/%", "%/美剧/%", "%/英剧/%", "%/日韩剧/%", "%/日韩电视剧/%", "%/日剧/%", "%/韩剧/%", "%/港剧/%", "%/台剧/%", "%/港台剧/%", "%/泰剧/%",
"%/日番/%", "%/国漫/%", "%/番剧/%", "%/动漫/%", "%/特别篇/%", "%/特別篇/%", "%/番外/%", "%/特典/%",
}
clauses := make([]string, 0, len(patterns)*2)
args := make([]any, 0, len(patterns)*2)
clauses := make([]string, 0, len(patterns))
args := make([]any, 0, len(patterns))
for _, pattern := range patterns {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
clauses = append(clauses, embyEpisodicPathExpr+" LIKE ?")
args = append(args, pattern)
if strings.Contains(pattern, "/") {
clauses = append(clauses, "LOWER(media.path) LIKE ?")
args = append(args, strings.ReplaceAll(pattern, "/", `\`))
}
}
return strings.Join(clauses, " OR "), args
}
+94 -5
View File
@@ -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)
}
}
+8
View File
@@ -66,6 +66,14 @@ var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
// ErrRootMutation protects configured roots such as /media and /downloads.
var ErrRootMutation = errors.New("refusing to mutate an allowed root")
// ResolvePath validates that path sits inside one of the allowed storage roots
// and returns its absolute form. Callers use it to accept a server-side path
// from the UI (file pickers) without re-implementing the boundary check.
func (s *FileManagerService) ResolvePath(path string) (string, error) {
abs, _, err := s.requireAllowedPath(path, false)
return abs, err
}
// List enumerates a directory under one of the allowed roots, returning up to
// maxEntries items sorted by (dir-first, path). Recursive listing is capped by
// maxEntries to avoid accidentally walking huge NAS trees from the UI.
+31
View File
@@ -153,6 +153,37 @@ func TestFileManagerRefusesRootMutation(t *testing.T) {
}
}
// TestFileManagerResolvePath 阅读模块的服务器选书/选目录入口靠它做边界校验:
// 允许根目录内放行,越界一律拒绝。
func TestFileManagerResolvePath(t *testing.T) {
root := t.TempDir()
svc := newFileManagerTestService(t, root)
inside := filepath.Join(root, "books", "小说.txt")
if err := os.MkdirAll(filepath.Dir(inside), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(inside, []byte("正文"), 0o644); err != nil {
t.Fatal(err)
}
got, err := svc.ResolvePath(inside)
if err != nil {
t.Fatalf("允许根目录内的路径应放行: %v", err)
}
if got != inside {
t.Fatalf("ResolvePath = %q,期望 %q", got, inside)
}
outside := filepath.Join(t.TempDir(), "越界.txt")
if err := os.WriteFile(outside, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := svc.ResolvePath(outside); !errors.Is(err, ErrPathOutOfBounds) {
t.Fatalf("越界路径 err = %v, want ErrPathOutOfBounds", err)
}
}
func hardlinksUnsupported(t *testing.T, root string) bool {
t.Helper()
src := filepath.Join(root, "hardlink-probe-src")
+11
View File
@@ -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 (
+42 -9
View File
@@ -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
+102
View File
@@ -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 {
+4 -1
View File
@@ -50,9 +50,12 @@ func LookupLocalAvailability(ctx context.Context, repo *repository.Container, ti
return out
}
like := "%" + query + "%"
// 方言相关的 ILIKE/LIKE:PostgreSQL 下用 LIKE 会因标题大小写与查询不一致而
// 漏判「本地已有该剧」,进而反复提示订阅缺失。
likeOp := repository.LikeOperator(repo.DB)
var rows []model.Media
if err := repo.DB.WithContext(ctx).
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
Where("title "+likeOp+" ? OR original_name "+likeOp+" ? OR path "+likeOp+" ?", like, like, like).
Order("season_num asc, episode_num asc, episode_fraction asc, created_at desc").
Limit(2000).
Find(&rows).Error; err != nil {
@@ -0,0 +1,44 @@
package reader
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// TestSaveAudioConfigPersistsCredits 听书跳过片头/片尾设置要落库,且只能改自己的书。
func TestSaveAudioConfigPersistsCredits(t *testing.T) {
svc, repos := newLoginTestService(t)
ctx := t.Context()
book := &model.ReaderBook{UserID: "u1", Name: "宠魅", Type: 1}
if err := repos.Reader.CreateBook(ctx, book); err != nil {
t.Fatal(err)
}
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 30, 15); err != nil {
t.Fatalf("保存听书设置失败: %v", err)
}
got, err := svc.GetBook(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if got.OpenCredits != 30 || got.CloseCredits != 15 {
t.Fatalf("片头/片尾 = %d/%d,期望 30/15", got.OpenCredits, got.CloseCredits)
}
// 0 是合法值(不跳过),必须能写回
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, 0, 0); err != nil {
t.Fatalf("清零失败: %v", err)
}
if got, _ = svc.GetBook(ctx, book.ID); got.OpenCredits != 0 || got.CloseCredits != 0 {
t.Fatalf("清零后 = %d/%d,期望 0/0", got.OpenCredits, got.CloseCredits)
}
if err := svc.SaveAudioConfig(ctx, "u1", book.ID, -1, 0); err == nil {
t.Fatal("负数应被拒绝")
}
if err := svc.SaveAudioConfig(ctx, "other", book.ID, 10, 10); err == nil {
t.Fatal("他人书架应被拒绝")
}
}
+374
View File
@@ -0,0 +1,374 @@
// 本地/远端有声书的按需转码:源文件是浏览器解不了的格式(典型是 WMA/ASF)时,
// 用 ffmpeg 转成 mp3 落到缓存目录,再按 Range 下发。
//
// 为什么落盘而不是实时流式输出:有声书必须能拖动进度,而 http.ServeContent 的
// Range 支持要求可随机读取的文件;实时管道没有长度信息,一旦 seek 就废掉。
// 一章通常几 MB,转一次几秒,之后同一章秒开。
package reader
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
"github.com/truewhile/MeBox/internal/helper"
)
// audioTranscodeFlight 同一章节并发命中时只跑一次 ffmpeg(按缓存路径去重)。
var audioTranscodeFlight singleflight.Group
// 转码互斥表:singleflight 只保证同一 key 不重复执行,prune 与写文件仍需串行。
var audioTranscodeMu sync.Mutex
const (
// audioTranscodeDirName 缓存目录名,位于 cache.cache_dir 之下。
audioTranscodeDirName = "reader-audio"
// maxAudioTranscodeCacheBytes 转码缓存上限,超出按修改时间淘汰最旧的。
// 1GB 是权衡:单章语音 96kbps 约 1MB/分钟,一本 10 小时的有声书约 600MB,
// 也就是说同时能保住的「最近听过的书」在个位数;超出后旧书再听会重新转码。
maxAudioTranscodeCacheBytes = 1 << 30
// audioTranscodeKeepRatio 触发淘汰后回落到上限的比例,避免每次写入都淘汰。
audioTranscodeKeepRatio = 0.9
// maxAudioTranscodeDuration 单章转码超时。
maxAudioTranscodeDuration = 15 * time.Minute
// audioTranscodeBitrate 语音内容 96k 足够,体积约为原 WMA 的两倍以内。
audioTranscodeBitrate = "96k"
)
// needsTranscodeAudioExt 明确需要转码的容器/编码(浏览器都无法直接解码)。
// 只列已知有问题的:未列出的格式维持原样直出,避免把本来能播的流也拖去转码。
var needsTranscodeAudioExt = map[string]bool{
".wma": true, ".asf": true, ".wmv": true, ".ape": true, ".wv": true,
".ac3": true, ".dts": true, ".amr": true, ".tta": true, ".dsf": true, ".dff": true,
}
// audioSourceExt 取音频地址的扩展名(小写带点)。
//
// 既要认本地路径(D:\x\a.wma),也要认带 query 的远端地址
// (…/video.wma?acct=…)——后者直接 filepath.Ext 会把 query 一起算进去。
func audioSourceExt(source string) string {
raw := strings.TrimSpace(source)
if raw == "" {
return ""
}
// scheme 长度 >1 才算真 URL:Windows 盘符(D:\…)会被 url.Parse 当成单字符 scheme
if u, err := url.Parse(raw); err == nil && len(u.Scheme) > 1 {
return strings.ToLower(filepath.Ext(u.Path))
}
if i := strings.IndexAny(raw, "?#"); i >= 0 {
raw = raw[:i]
}
return strings.ToLower(filepath.Ext(raw))
}
// needsAudioTranscode 判断该音频是否必须转码后才能给浏览器播放。
func needsAudioTranscode(source string) bool {
return needsTranscodeAudioExt[audioSourceExt(source)]
}
// ffmpegBinary 解析可用的 ffmpeg 可执行文件;找不到返回空串。
func (s *ReaderService) ffmpegBinary() string {
path := strings.TrimSpace(s.cfg.App.FFmpegPath)
if path == "" {
path = "ffmpeg"
}
resolved, err := exec.LookPath(path)
if err != nil {
return ""
}
return resolved
}
// ErrAudioTranscodeUnavailable 服务器没有 ffmpeg,无法转码该格式。
var ErrAudioTranscodeUnavailable = errors.New("服务器未安装 ffmpeg")
// transcodeMissingFFmpegError 给前端一条能直接照做的提示。
func transcodeMissingFFmpegError(source string) error {
ext := strings.TrimPrefix(audioSourceExt(source), ".")
if ext == "" {
ext = "该"
}
return fmt.Errorf("这个音频是 %s 格式,浏览器无法直接播放,需要服务器转码;%w,请在设置里配置 app.ffmpeg_path 或安装 ffmpeg",
strings.ToUpper(ext), ErrAudioTranscodeUnavailable)
}
// ─── 签名(与本地音频同一套:<audio src> 带不上 JWT) ─────────────────────
func (s *ReaderService) signAudioTranscode(bookID, source string) string {
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(bookID + "|transcode|" + source))
return hex.EncodeToString(mac.Sum(nil))[:32]
}
// AudioTranscodeURL 需要转码的音频地址(签名代理)。
func (s *ReaderService) AudioTranscodeURL(bookID, source string) string {
return "/api/reader/audio/transcode?b=" + url.QueryEscape(bookID) +
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(source)) +
"&s=" + s.signAudioTranscode(bookID, source)
}
// VerifyAudioTranscodeURL 校验签名并还原原始音频地址(本地路径或远端 URL)。
func (s *ReaderService) VerifyAudioTranscodeURL(bookID, encoded, sig string) (string, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("转码地址解码失败")
}
expect := s.signAudioTranscode(bookID, string(raw))
if !hmac.Equal([]byte(expect), []byte(sig)) {
return "", fmt.Errorf("转码签名校验失败")
}
return string(raw), nil
}
// ─── 缓存与转码 ──────────────────────────────────────────────────────────
func (s *ReaderService) audioTranscodeDir() (string, error) {
base := strings.TrimSpace(s.cfg.Cache.CacheDir)
if base == "" {
base = filepath.Join(s.cfg.App.DataDir, "cache")
}
dir := filepath.Join(base, audioTranscodeDirName)
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", fmt.Errorf("创建转码缓存目录失败: %w", err)
}
return dir, nil
}
// audioTranscodeCachePath 缓存文件名由(书 + 源地址)哈希决定:同一章重复播放直接命中。
func audioTranscodeCachePath(dir, bookID, source string) string {
sum := sha256.Sum256([]byte(bookID + "|" + source))
return filepath.Join(dir, hex.EncodeToString(sum[:16])+".mp3")
}
// AudioTranscodeCachePath 返回该音轨的转码缓存文件路径(不触发转码),
// 供测试与运维排查使用。
func (s *ReaderService) AudioTranscodeCachePath(bookID, source string) (string, error) {
dir, err := s.audioTranscodeDir()
if err != nil {
return "", err
}
return audioTranscodeCachePath(dir, bookID, source), nil
}
// EnsureTranscodedAudio 确保该音频已有转码结果,返回可 Range 下发的 mp3 路径。
func (s *ReaderService) EnsureTranscodedAudio(ctx context.Context, bookID, source string) (string, error) {
if strings.TrimSpace(source) == "" {
return "", fmt.Errorf("缺少音频地址")
}
dir, err := s.audioTranscodeDir()
if err != nil {
return "", err
}
dst := audioTranscodeCachePath(dir, bookID, source)
if ok := touchCachedAudio(dst); ok {
return dst, nil
}
if s.ffmpegBinary() == "" {
return "", transcodeMissingFFmpegError(source)
}
v, err, _ := audioTranscodeFlight.Do(dst, func() (any, error) {
// 排队期间别的请求可能已经转好了
if ok := touchCachedAudio(dst); ok {
return dst, nil
}
if err := s.transcodeAudioFile(ctx, bookID, source, dst, dir); err != nil {
return nil, err
}
return dst, nil
})
if err != nil {
return "", err
}
path, _ := v.(string)
if path == "" {
return "", fmt.Errorf("转码结果不可用")
}
return path, nil
}
// touchCachedAudio 命中缓存时刷新访问时间,作为 LRU 依据。
func touchCachedAudio(path string) bool {
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() == 0 {
return false
}
now := time.Now()
_ = os.Chtimes(path, now, now)
return true
}
// transcodeAudioFile 跑一次 ffmpeg 并原子落盘(先写 .part 再 rename)。
func (s *ReaderService) transcodeAudioFile(ctx context.Context, bookID, source, dst, dir string) error {
ffmpeg := s.ffmpegBinary()
if ffmpeg == "" {
return transcodeMissingFFmpegError(source)
}
remote := isRemoteMediaURL(source)
if !remote {
if info, err := os.Stat(source); err != nil || info.IsDir() {
return fmt.Errorf("音频文件已丢失")
}
}
// 转码与淘汰串行,避免边写边删
audioTranscodeMu.Lock()
pruneAudioTranscodeCache(dir, maxAudioTranscodeCacheBytes)
audioTranscodeMu.Unlock()
tmp := dst + ".part"
defer os.Remove(tmp)
headers := s.transcodeInputHeaders(ctx, bookID, source)
// 客户端断开不该杀掉已开始的转码:产物对下次播放仍然有用
runCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), maxAudioTranscodeDuration)
defer cancel()
cmd := exec.CommandContext(runCtx, ffmpeg, buildFFmpegAudioArgs(source, tmp, headers)...)
out, err := cmd.CombinedOutput()
if err != nil {
if runCtx.Err() == context.DeadlineExceeded {
return fmt.Errorf("转码超时(超过 %s)", maxAudioTranscodeDuration)
}
return fmt.Errorf("转码失败: %v: %s", err, truncateForError(string(out), 300))
}
info, err := os.Stat(tmp)
if err != nil || info.Size() == 0 {
return fmt.Errorf("转码没有产生有效输出")
}
if err := os.Rename(tmp, dst); err != nil {
return fmt.Errorf("写入转码缓存失败: %w", err)
}
return nil
}
// transcodeInputHeaders 远端音源要带的请求头:浏览器 UA 预设 + 书源请求头 + 登录态。
// 本机 /api/strm 这类内网地址不需要,但书源直链类的有声书要靠它才能取到。
func (s *ReaderService) transcodeInputHeaders(ctx context.Context, bookID, source string) map[string]string {
if !isRemoteMediaURL(source) || s.repo == nil {
return nil
}
headers := map[string]string{}
for k, v := range helper.HTTPHeaderPresets() {
headers[k] = v
}
book, err := s.repo.GetBook(ctx, bookID)
if err != nil || book == nil || strings.TrimSpace(book.Origin) == "" {
return headers
}
if src, err := s.repo.GetSourceAnyByURL(ctx, book.Origin); err == nil && src != nil && src.Header != "" {
var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra {
headers[k] = fmt.Sprintf("%v", v)
}
}
}
state := s.newSourceState(ctx, book.Origin)
for k, v := range state.LoginHeaderMap() {
if !strings.EqualFold(k, "cookie") {
headers[k] = v
}
}
if ck := state.CookieForRequest(source); ck != "" {
headers["Cookie"] = ck
}
// 默认 Referer 同上:聚合类书源的 origin 是显示名,拼出来的 Referer 非法,
// 会被音源/图床判盗链。只在 origin 是真正的 http(s) 地址时才补。
if headers["Referer"] == "" {
if referer := sourceReferer(book.Origin); referer != "" {
headers["Referer"] = referer
}
}
return headers
}
// buildFFmpegAudioArgs 组装音频转码参数(纯函数,便于测试)。
// 只取第一条音频流:WMA 常把专辑封面挂在视频流上,-vn 一并丢掉。
func buildFFmpegAudioArgs(source, output string, headers map[string]string) []string {
args := []string{"-hide_banner", "-loglevel", "error", "-nostdin", "-y"}
if len(headers) > 0 {
keys := make([]string, 0, len(headers))
for k := range headers {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString(": ")
b.WriteString(headers[k])
b.WriteString("\r\n")
}
args = append(args, "-headers", b.String())
}
return append(args,
"-i", source,
"-vn", "-map", "0:a:0",
"-c:a", "libmp3lame", "-b:a", audioTranscodeBitrate,
"-f", "mp3", output,
)
}
// pruneAudioTranscodeCache 缓存超过上限时按访问时间淘汰,回落到上限的 90%。
func pruneAudioTranscodeCache(dir string, maxBytes int64) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type cacheItem struct {
path string
size int64
mod time.Time
}
items := make([]cacheItem, 0, len(entries))
var total int64
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".mp3") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
items = append(items, cacheItem{filepath.Join(dir, e.Name()), info.Size(), info.ModTime()})
total += info.Size()
}
if total <= maxBytes {
return
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.Before(items[j].mod) })
target := int64(float64(maxBytes) * audioTranscodeKeepRatio)
for _, it := range items {
if total <= target {
break
}
if os.Remove(it.path) == nil {
total -= it.size
}
}
}
// truncateForError 截断 ffmpeg 输出,避免把整段 stderr 塞进响应。
func truncateForError(s string, limit int) string {
s = strings.TrimSpace(s)
if len(s) <= limit {
return s
}
return s[:limit] + "…"
}
@@ -0,0 +1,305 @@
package reader
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
// TestAudioSourceExt 音频地址取扩展名:本地路径、带 query 的远端地址、
// Windows 盘符(会被 url.Parse 当成单字符 scheme)都要认对。
func TestAudioSourceExt(t *testing.T) {
cases := []struct {
in string
want string
}{
{`D:\media\斗破苍穹\001-250\001.wma`, ".wma"},
{"/media/books/01.WMA", ".wma"},
{"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x&path=y", ".wma"},
{"https://cdn.example.com/a/b.mp3?token=1#frag", ".mp3"},
{"https://cdn.example.com/stream", ""},
{"/media/books/01.m4a", ".m4a"},
{"", ""},
}
for _, tc := range cases {
if got := audioSourceExt(tc.in); got != tc.want {
t.Errorf("audioSourceExt(%q) = %q,期望 %q", tc.in, got, tc.want)
}
}
}
// TestNeedsAudioTranscode 只对浏览器确定解不了的格式转码,其余维持直出。
func TestNeedsAudioTranscode(t *testing.T) {
need := []string{
`D:\media\001.wma`,
"http://127.0.0.1:8080/api/strm/play/cloud115/video.wma?acct=x",
"/media/01.asf",
"/media/01.ape",
}
for _, s := range need {
if !needsAudioTranscode(s) {
t.Errorf("%q 应需要转码", s)
}
}
direct := []string{
"/media/01.mp3", "/media/01.m4a", "/media/01.m4b", "/media/01.flac",
"/media/01.ogg", "/media/01.opus", "/media/01.wav",
"https://cdn.example.com/stream", "",
}
for _, s := range direct {
if needsAudioTranscode(s) {
t.Errorf("%q 不该被转码", s)
}
}
}
// TestAudioTranscodeURLSignature 转码地址的签名可校验、改一处即失效。
func TestAudioTranscodeURLSignature(t *testing.T) {
svc := newLocalBookService(t)
source := `D:\media\斗破苍穹\001-250\001.wma`
raw := svc.AudioTranscodeURL("book-1", source)
if !strings.HasPrefix(raw, "/api/reader/audio/transcode?b=book-1&u=") {
t.Fatalf("转码地址格式不对: %q", raw)
}
encoded, sig := queryParam(t, raw, "u"), queryParam(t, raw, "s")
got, err := svc.VerifyAudioTranscodeURL("book-1", encoded, sig)
if err != nil || got != source {
t.Fatalf("签名校验失败: %v / %q", err, got)
}
if _, err := svc.VerifyAudioTranscodeURL("book-1", encoded, "deadbeef"); err == nil {
t.Fatal("错误签名应校验失败")
}
if _, err := svc.VerifyAudioTranscodeURL("book-2", encoded, sig); err == nil {
t.Fatal("换一本书后签名应失效")
}
}
// TestAudioTranscodeCachePath 缓存名对(书 + 源地址)稳定且互不冲突。
func TestAudioTranscodeCachePath(t *testing.T) {
dir := t.TempDir()
a := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
b := audioTranscodeCachePath(dir, "book-1", "/x/001.wma")
if a != b {
t.Fatalf("同一输入应得到同一缓存路径: %q vs %q", a, b)
}
if !strings.HasSuffix(a, ".mp3") {
t.Fatalf("缓存文件应为 mp3: %q", a)
}
for _, other := range []string{
audioTranscodeCachePath(dir, "book-2", "/x/001.wma"),
audioTranscodeCachePath(dir, "book-1", "/x/002.wma"),
} {
if other == a {
t.Fatalf("不同书/不同章节不该共用缓存: %q", other)
}
}
}
// TestEnsureTranscodedAudioWithoutFFmpeg 没装 ffmpeg 时要给出可照做的错误。
func TestEnsureTranscodedAudioWithoutFFmpeg(t *testing.T) {
svc := newLocalBookService(t)
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
_, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", `D:\media\001.wma`)
if !errors.Is(err, ErrAudioTranscodeUnavailable) {
t.Fatalf("err = %v,应包含 ErrAudioTranscodeUnavailable", err)
}
if !strings.Contains(err.Error(), "WMA") || !strings.Contains(err.Error(), "ffmpeg") {
t.Fatalf("错误信息应说明格式与解决办法,实际 %q", err.Error())
}
}
// TestEnsureTranscodedAudioUsesCache 已有转码结果时直接命中缓存,不依赖 ffmpeg。
func TestEnsureTranscodedAudioUsesCache(t *testing.T) {
svc := newLocalBookService(t)
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
source := `D:\media\001.wma`
dir, err := svc.audioTranscodeDir()
if err != nil {
t.Fatal(err)
}
want := audioTranscodeCachePath(dir, "book-1", source)
if err := os.WriteFile(want, []byte("MP3DATA"), 0o640); err != nil {
t.Fatal(err)
}
got, err := svc.EnsureTranscodedAudio(t.Context(), "book-1", source)
if err != nil {
t.Fatalf("命中缓存时不该报错: %v", err)
}
if got != want {
t.Fatalf("缓存路径 = %q,期望 %q", got, want)
}
data, err := os.ReadFile(got)
if err != nil || string(data) != "MP3DATA" {
t.Fatalf("缓存内容被改动了: %q / %v", data, err)
}
}
// TestBuildFFmpegAudioArgs ffmpeg 参数:输入输出位置正确,丢掉视频流,
// 强制 mp3 编码,请求头按 key 排序保证可复现。
func TestBuildFFmpegAudioArgs(t *testing.T) {
args := buildFFmpegAudioArgs("/in/01.wma", "/out/01.mp3", nil)
if args[0] != "-hide_banner" || args[len(args)-1] != "/out/01.mp3" {
t.Fatalf("参数首尾不对: %v", args)
}
joined := strings.Join(args, " ")
for _, want := range []string{"-i /in/01.wma", "-vn", "-map 0:a:0", "-c:a libmp3lame", "-f mp3"} {
if !strings.Contains(joined, want) {
t.Errorf("参数缺少 %q: %v", want, args)
}
}
withHeaders := buildFFmpegAudioArgs("u", "o", map[string]string{
"User-Agent": "mebox", "Cookie": "a=1",
})
idx := indexOf(withHeaders, "-headers")
if idx < 0 {
t.Fatalf("应带上 -headers: %v", withHeaders)
}
headers := withHeaders[idx+1]
if !strings.HasPrefix(headers, "Cookie: a=1\r\n") || !strings.Contains(headers, "User-Agent: mebox\r\n") {
t.Fatalf("请求头内容或顺序不对: %q", headers)
}
}
// TestPruneAudioTranscodeCache 超过上限时按访问时间淘汰最旧的,落到 90%。
func TestPruneAudioTranscodeCache(t *testing.T) {
dir := t.TempDir()
base := time.Now().Add(-time.Hour)
sizes := []int64{400, 400, 400}
names := []string{"old.mp3", "mid.mp3", "new.mp3"}
var total int64
for i, name := range names {
if err := os.WriteFile(filepath.Join(dir, name), make([]byte, sizes[i]), 0o640); err != nil {
t.Fatal(err)
}
ts := base.Add(time.Duration(i) * time.Minute)
if err := os.Chtimes(filepath.Join(dir, name), ts, ts); err != nil {
t.Fatal(err)
}
total += sizes[i]
}
// 上限设为总量的一半:必须淘汰
pruneAudioTranscodeCache(dir, total/2)
if _, err := os.Stat(filepath.Join(dir, "old.mp3")); !os.IsNotExist(err) {
t.Errorf("最旧的缓存应被删除,stat err = %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "new.mp3")); err != nil {
t.Errorf("最新的缓存应保留: %v", err)
}
}
// TestLocalAudioTrackWithoutFFmpeg localAudioTrack 在需要转码但没 ffmpeg 时,
// 直接给出可读的错误,而不是返回一个注定播不了的地址。
func TestLocalAudioTrackWithoutFFmpeg(t *testing.T) {
svc := newLocalBookService(t)
svc.cfg.App.FFmpegPath = filepath.Join(t.TempDir(), "definitely-missing-ffmpeg")
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.wma`}
_, transcoding, err := svc.localAudioTrack(book, ch)
if err == nil {
t.Fatal("缺 ffmpeg 时应报错")
}
if transcoding {
t.Error("报错时不该标记为转码中")
}
if !strings.Contains(err.Error(), "ffmpeg") {
t.Fatalf("错误信息应提到 ffmpeg: %q", err.Error())
}
}
// TestLocalAudioTrackPlayableStaysDirect 浏览器能播的格式不转码,仍走本地音频端点。
func TestLocalAudioTrackPlayableStaysDirect(t *testing.T) {
svc := newLocalBookService(t)
book := &model.ReaderBook{Base: model.Base{ID: "book-1"}, Type: 1}
ch := model.ReaderChapter{Index: 0, Tag: `D:\media\001.mp3`}
track, transcoding, err := svc.localAudioTrack(book, ch)
if err != nil {
t.Fatalf("mp3 不该报错: %v", err)
}
if transcoding {
t.Error("mp3 不该标记为转码中")
}
if !strings.HasPrefix(track, "/api/reader/local/audio?") {
t.Fatalf("mp3 应走本地音频端点: %q", track)
}
}
// queryParam 取查询参数(签名用例共用)。
func queryParam(t *testing.T, raw, key string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return u.Query().Get(key)
}
func indexOf(items []string, want string) int {
for i, v := range items {
if v == want {
return i
}
}
return -1
}
// 转码缓存上限是 1GB:单章语音约 1MB/分钟(96kbps),
// 也就是一本 10 小时的有声书约 600MB,同时能保住「最近听过的书」在个位数。
// 这里把常量与淘汰后的落点一起钉住,防止以后被无意改回大值。
func TestAudioTranscodeCacheLimitIsOneGiB(t *testing.T) {
if maxAudioTranscodeCacheBytes != int64(1)<<30 {
t.Fatalf("转码缓存上限应为 1GB,实际 %d 字节", maxAudioTranscodeCacheBytes)
}
// 淘汰目标是上限的 90%:模拟缓存略超上限时,删掉最旧的若干章回到目标以下。
dir := t.TempDir()
limit := int64(1000)
perFile := int64(120)
for i := 0; i < 9; i++ { // 1080 > 1000,超限
name := filepath.Join(dir, fmt.Sprintf("ch%02d.mp3", i))
if err := os.WriteFile(name, make([]byte, perFile), 0o640); err != nil {
t.Fatal(err)
}
ts := time.Now().Add(-time.Duration(9-i) * time.Minute)
if err := os.Chtimes(name, ts, ts); err != nil {
t.Fatal(err)
}
}
pruneAudioTranscodeCache(dir, limit)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var remain int64
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
remain += info.Size()
}
if remain > limit {
t.Fatalf("淘汰后应落回上限内: remain=%d limit=%d", remain, limit)
}
// 最新的那章必须还在(LRU 先删最旧的)。
if _, err := os.Stat(filepath.Join(dir, "ch08.mp3")); err != nil {
t.Fatalf("最新一章应保留: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "ch00.mp3")); !os.IsNotExist(err) {
t.Fatalf("最旧一章应被删除,err=%v", err)
}
}
@@ -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
}
+91
View File
@@ -0,0 +1,91 @@
package reader
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
// 本文件:详情 init 链式取值 与 书籍类型翻译 的回归测试。
//
// 背景(光遇聚合):
// - ruleBookInfo.init 是 `<js>…</js>$.data` 组合规则,它的结果会成为后续
// name/author/tocUrl 的解析内容(对应 legado
// `analyzeRule.setContent(analyzeRule.getElement(infoRule.init))`)。
// MeBox 早期把 init 结果当成 JSON 解析 map 再按写死的键名取值,导致
// 详情页「书名/作者为空、目录 0 章」。
// - 书源在目录规则里用 legado 的 BookType 位掩码给 book.type 赋值
// (8=文本 32=音频 64=图片 4=视频),而 MeBox 用 0/1/2/3,必须换算,
// 否则听书源会被当成文本,正文渲染成一串裸 URL。
// TestBookInfoInitResultBecomesContent init 的结果要成为后续字段的解析内容。
func TestBookInfoInitResultBecomesContent(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")
switch r.URL.Path {
case "/detail":
_, _ = w.Write([]byte(`{"code":0,"data":{"book_name":"宠魅","author":"某作者","thumb_url":"https://img.example.com/c.jpg","toc_url":"https://example.com/toc"}}`))
default:
_, _ = w.Write([]byte(`<html><body>book page</body></html>`))
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
src := map[string]any{
"bookSourceUrl": srv.URL,
"bookSourceName": "init 链式测试源",
"ruleBookInfo": map[string]any{
// 组合规则:先跑 JS 取详情接口,再用 $.data 取出对象
"init": fmt.Sprintf(`<js>java.ajax(%q)</js>$.data`, srv.URL+"/detail"),
"name": "$.book_name",
"author": "$.author",
"coverUrl": "$.thumb_url",
"tocUrl": "$.toc_url",
},
}
out, _ := json.Marshal(src)
sourceID := prepareLoginSource(t, svc, string(out))
info, err := svc.GetBookInfo(t.Context(), sourceID, "", srv.URL+"/book/1")
if err != nil {
t.Fatalf("取详情失败: %v", err)
}
if info.Name != "宠魅" {
t.Fatalf("书名 = %q(init 结果未成为解析内容)", info.Name)
}
if info.Author != "某作者" {
t.Fatalf("作者 = %q", info.Author)
}
if info.CoverURL != "https://img.example.com/c.jpg" {
t.Fatalf("封面 = %q", info.CoverURL)
}
if info.TocURL != "https://example.com/toc" {
t.Fatalf("目录地址 = %q", info.TocURL)
}
}
// TestNormalizeBookType 把 legado 的 BookType 位掩码换算成 MeBox 的 0/1/2/3。
func TestNormalizeBookType(t *testing.T) {
cases := map[int]int{
0: 0, // 未知/默认 → 文本
8: 0, // text
32: 1, // audio(听书源默认值)
64: 2, // image(漫画源默认值)
4: 3, // video(短剧源默认值)
1: 0, // 苹果端遗留值不再是音频
2: 0,
// 组合位:按 音频 > 图片 > 视频 > 文本 的优先级取一个
32 | 8: 1,
64 | 8: 2,
4 | 8: 3,
32 | 64: 1,
}
for in, want := range cases {
if got := normalizeBookType(in); got != want {
t.Errorf("normalizeBookType(%d) = %d,期望 %d", in, got, want)
}
}
}
+147
View File
@@ -0,0 +1,147 @@
package reader
import (
"fmt"
"testing"
)
// 本文件:书架未读章数所需的「网络书籍总章数从目录缓存补齐」逻辑回归测试。
// 前端的未读章数 = total_chapter_num -(已读到第几章),所以这里保证 total_chapter_num 可靠。
func TestListBooksFillsChapterCountFromCache(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
Origin: "https://example.com",
OriginName: "测试书源",
BookURL: "https://example.com/book/1",
}, "测试书", "作者", "")
if err != nil {
t.Fatalf("加入书架失败: %v", err)
}
if book.TotalChapterNum != 0 {
t.Fatalf("刚加入书架的网文总章数 = %d,期望 0", book.TotalChapterNum)
}
// 目录还没缓存:书架读不到总章数,前端此时不显示未读徽标
books, err := svc.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if len(books) != 1 || books[0].TotalChapterNum != 0 {
t.Fatalf("未缓存目录时书架 = %+v,期望 1 本且总章数 0", books)
}
// 缓存 5 章后,书架应能算出总章数
chapters := make([]ChapterInput, 0, 5)
for i := 0; i < 5; i++ {
chapters = append(chapters, ChapterInput{
Index: i,
Title: fmt.Sprintf("第 %d 章", i+1),
URL: fmt.Sprintf("https://example.com/c/%d", i),
})
}
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
t.Fatalf("保存目录失败: %v", err)
}
books, err = svc.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].TotalChapterNum != 5 {
t.Fatalf("缓存 5 章后总章数 = %d,期望 5", books[0].TotalChapterNum)
}
// 读到第 2 章后,前端用 dur_chapter_index + 1 算已读;这里确认进度按原样回读
if err := svc.SaveProgress(ctx, "u1", book.ID, 1, 0, "第 2 章"); err != nil {
t.Fatalf("保存进度失败: %v", err)
}
books, err = svc.ListBooks(ctx, "u1")
if err != nil {
t.Fatal(err)
}
if books[0].DurChapterIndex != 1 || books[0].DurChapterTime == 0 {
t.Fatalf("进度 = index %d / time %d,期望 index 1 且有时间戳", books[0].DurChapterIndex, books[0].DurChapterTime)
}
}
func TestListBooksChapterCountIsPerUser(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/1",
}, "测试书", "", "")
if err != nil {
t.Fatal(err)
}
if err := svc.SaveChapters(ctx, book.ID, []ChapterInput{{Index: 0, Title: "第 1 章", URL: "u"}}); err != nil {
t.Fatal(err)
}
// 别人的书架不应该受影响,也不该拿到别人的书
other, err := svc.ListBooks(ctx, "u2")
if err != nil {
t.Fatal(err)
}
if len(other) != 0 {
t.Fatalf("u2 的书架应为空,得到 %+v", other)
}
}
func TestWarmUpBookChaptersSkipsWhenCacheExists(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
book, err := svc.AddBook(ctx, "u1", SearchOrigin{
Origin: "https://example.com",
BookURL: "https://example.com/book/1",
}, "测试书", "", "")
if err != nil {
t.Fatal(err)
}
chapters := []ChapterInput{
{Index: 0, Title: "第 1 章", URL: "https://example.com/c/0"},
{Index: 1, Title: "第 2 章", URL: "https://example.com/c/1"},
}
if err := svc.SaveChapters(ctx, book.ID, chapters); err != nil {
t.Fatal(err)
}
// 已有目录缓存时直接返回,不联网、不覆盖缓存
svc.WarmUpBookChapters(ctx, "u1", book)
got, err := svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Title != "第 1 章" {
t.Fatalf("缓存被改动: %+v", got)
}
}
func TestWarmUpBookChaptersSkipsLocalBook(t *testing.T) {
svc := newLocalBookService(t)
ctx := t.Context()
book, err := svc.ImportLocalBook(ctx, "u1", "测试小说.txt", []byte(sampleTXT))
if err != nil {
t.Fatalf("导入失败: %v", err)
}
// 本地书籍不应触发联网抓目录(书源为空的地址必然失败),章节保持导入时的结果
svc.WarmUpBookChapters(ctx, "u1", book)
got, err := svc.ListChapters(ctx, book.ID)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("本地书籍章节数 = %d,期望 3", len(got))
}
if book.TotalChapterNum != 3 {
t.Fatalf("本地书籍总章数 = %d,期望 3", book.TotalChapterNum)
}
}
@@ -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&para=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&para=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)
}
}
+957
View File
@@ -0,0 +1,957 @@
package reader
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"github.com/PuerkitoBio/goquery"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/helper"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:书源 JS 的宿主浏览器(java.startBrowser / java.startBrowserAwait)。
//
// legado 用内置 WebView 承载页面:书源在自己的 JS 里拼一段 HTML(线路设置页
// 之类)或给一个网址,让用户点选、登录、过防爬校验,然后(Await 版本)把用户
// 操作后的页面源码当成 StrResponse 返回,书源再从中解析出结果写回源变量。
//
// 服务端没有 WebView,于是把这一段拆成三步:
// 1. JS 侧 startBrowserAwait 登记一个待办并阻塞等待;
// 2. 前端轮询到待办,在 <iframe> 里承载页面(同源,因此可以直接读回 DOM);
// 3. 用户点 √ 后把 iframe 的 DOM 回传,阻塞解除,JS 拿到 body()。
//
// 页面来源分两种,承载方式不同:
// - data:text/html;base64,…:书源自带的内嵌页面,直接解码返回;
// - http(s):服务端带书源 Cookie/请求头抓取后返回。这一步很关键——书源的
// 登录态存在服务端,浏览器里没有这些 Cookie,若直接让浏览器打开源站,
// 「用户后台」这类页面必然是未登录状态。页面里的资源地址会改写到同源
// 代理,使后续请求同样带上 Cookie。
const (
// browserAwaitTimeout 单次等待用户操作的上限。
browserAwaitTimeout = 10 * time.Minute
// browserPageTTL 待办条目与页面链接的有效期。
browserPageTTL = 15 * time.Minute
// browserMaxPageBytes 服务端抓取页面/资源的大小上限。
browserMaxPageBytes = 4 << 20
// browserFetchTimeout 抓取待承载页面/资源的超时。
browserFetchTimeout = 20 * time.Second
// browserModeWait 需要回传 DOM(startBrowserAwait)。
browserModeWait = "wait"
// browserModeOpen 只展示(startBrowser / showBrowser)。
browserModeOpen = "open"
)
// BrowserPage 前端待承载的一个页面(登录对话框轮询用)。
type BrowserPage struct {
ID string `json:"id"`
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。
Refetch bool `json:"refetch"`
// SourceID 发起该请求的书源。
SourceID string `json:"source_id"`
// TargetURL 原始地址(data: 与超长地址不回传)。
TargetURL string `json:"target_url,omitempty"`
}
// BrowserPageSnapshot 承载页面时返回的数据。
type BrowserPageSnapshot struct {
ID string
HTML string
SourceID string
FinalURL string
}
// 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
result rule.BrowserResult
err error
finished bool
}
// browserHost 把服务层的浏览器面板绑定到某个书源会话(实现 rule.BrowserHost)。
type browserHost struct {
svc *ReaderService
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, 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, 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, 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
}
defer s.dropBrowser(entry.id)
timeout := time.NewTimer(browserAwaitTimeout)
defer timeout.Stop()
select {
case <-entry.done:
entry.mu.Lock()
defer entry.mu.Unlock()
if entry.err != nil {
return rule.BrowserResult{}, entry.err
}
return entry.result, nil
case <-ctx.Done():
return rule.BrowserResult{}, ctx.Err()
case <-timeout.C:
return rule.BrowserResult{}, fmt.Errorf("等待页面操作超时(%s),请重试", browserAwaitTimeout)
}
}
// openBrowser 登记待办但不等待(页面展示给用户即可)。
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, cookies browserCookieTarget, req rule.BrowserTask, mode string) (*pendingBrowser, error) {
// 先分配 ID:页面里的资源代理地址需要用它签名。
id := newBrowserID()
html, finalURL, err := s.prepareBrowserPage(ctx, sourceURL, id, cookies, req)
if err != nil {
return nil, err
}
if finalURL == "" {
finalURL = req.URL
}
entry := &pendingBrowser{
id: id,
sourceID: 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()
if s.browserPending == nil {
s.browserPending = map[string]*pendingBrowser{}
}
s.pruneBrowsersLocked()
s.browserSeq++
entry.seq = s.browserSeq
s.browserPending[id] = entry
s.browserMu.Unlock()
if s.log != nil {
s.log.Info("reader:待用户完成页面操作",
zap.String("source", sourceID), zap.String("mode", mode), zap.String("title", req.Title))
}
return entry, nil
}
// browserBridgeScript 注入到承载页面里:父窗口请求时回传当前 DOM,把页面自己的
// fetch / XMLHttpRequest 转交给父窗口代为请求,并补上沙箱里不可用的存储对象。
//
// 为什么不让父窗口直接读 contentDocument:那要求 iframe 同源,而书源页面是
// 第三方 HTML,拿到同源权限就能读写 MeBox 自己的页面与 localStorage(JWT)。
// 改成 postMessage 后,iframe 可以只开 allow-scripts(不透明源)。
//
// 为什么要补存储对象:不透明源里访问 localStorage / sessionStorage / document.cookie
// 会直接抛 SecurityError,SPA 类的「用户后台」在启动读 token 时就崩了,
// 表现是一直停在「正在安全加载」。这里用内存版兜住,既不给出同源权限,
// 又让页面能正常跑完启动流程。
//
// 为什么要代发请求:iframe 是不透明源,页面自己的 XHR 既带不上书源 Cookie,
// 也会被 CORS 拦掉——靠接口取数的页面会取不到数据。
const browserBridgeScript = `<script data-mebox-browser-bridge="1">(function(){` +
`if(window.__meboxBridgeInstalled){return}window.__meboxBridgeInstalled=true;` +
// ── 沙箱内存储兜底 ──
`function memStore(){var d={};return{` +
`getItem:function(k){k=String(k);return Object.prototype.hasOwnProperty.call(d,k)?d[k]:null},` +
`setItem:function(k,v){d[String(k)]=String(v)},` +
`removeItem:function(k){delete d[String(k)]},` +
`clear:function(){d={}},` +
`key:function(i){var ks=Object.keys(d);return i>=0&&i<ks.length?ks[i]:null},` +
`get length(){return Object.keys(d).length}}}` +
`function patchStore(n){try{var s=memStore();Object.defineProperty(window,n,{configurable:true,get:function(){return s}})}catch(e){}}` +
`patchStore('localStorage');patchStore('sessionStorage');` +
// document.cookie 也要给:这类页面常用它判断是否已登录(真实 Cookie 在服务端,
// 浏览器里没有)。把书源在该站点的 Cookie 预置进去,页面才会正常渲染。
`var ck={};` +
`(function(seed){String(seed||'').split(';').forEach(function(p){var i=p.indexOf('=');if(i>0){ck[p.slice(0,i).trim()]=p.slice(i+1).trim()}})})` + "(`__MEBOX_COOKIE__`)" + `;` +
`try{Object.defineProperty(document,'cookie',{configurable:true,` +
`get:function(){var o=[];for(var k in ck){o.push(k+'='+ck[k])}return o.join('; ')},` +
`set:function(v){try{var p=String(v).split(';')[0].split('=');if(p[0]){ck[p[0].trim()]=p.slice(1).join('=')}}catch(e){}}})}catch(e){}` +
// 书源页面自身的基地址:相对地址要按它解析,而不是按承载地址(我们自己的源)
`var BASE=` + "`__MEBOX_BASE_URL__`" + `;` +
`function abs(u){try{return new URL(u,BASE||location.href).href}catch(e){try{return new URL(u,location.href).href}catch(e2){return u}}}` +
// ── DOM 回传 ──
`window.addEventListener('message',function(e){` +
`if(!e||e.data!=='__mebox_dom__'){return}` +
`try{parent.postMessage({__mebox_dom__:true,html:document.documentElement.outerHTML},'*')}catch(err){}` +
`},false);` +
// ── 请求代理(父窗口持有登录态,由它转交服务端补 Cookie) ──
`var seq=0,waiters={};` +
`window.addEventListener('message',function(e){` +
`var d=e&&e.data;` +
`if(!d||d.__mebox_proxy_res__!==true){return}` +
`var w=waiters[d.reqId];` +
`if(!w){return}delete waiters[d.reqId];w(d);` +
`},false);` +
`function rpc(method,url,headers,body){` +
`return new Promise(function(resolve,reject){` +
`var id='r'+(++seq);waiters[id]=resolve;` +
`try{parent.postMessage({__mebox_proxy__:true,reqId:id,url:url,method:method,headers:headers||{},body:body||''},'*')}catch(e){delete waiters[id];reject(e);return}` +
`setTimeout(function(){if(waiters[id]){delete waiters[id];reject(new Error('mebox proxy timeout'))}},60000);` +
`})}` +
`function decode(res){return res&&res.base64?atob(res.body||''):((res&&res.body)||'')}` +
`var origFetch=window.fetch;` +
`if(origFetch){window.fetch=function(input,init){` +
`init=init||{};` +
`var url=typeof input==='string'?input:(input&&input.url);` +
`if(!url){return origFetch.apply(this,arguments)}` +
`var method=String(init.method||(input&&input.method)||'GET').toUpperCase();` +
`var headers={};` +
`try{new Headers(init.headers||(input&&input.headers)||{}).forEach(function(v,k){headers[k]=v})}catch(e){}` +
`var body=init.body?String(init.body):'';` +
`return rpc(method,abs(url),headers,body).then(function(res){` +
`return new Response(decode(res),{status:(res&&res.status)||200,headers:{'Content-Type':(res&&res.contentType)||'text/plain'}})` +
`})}}` +
`var OrigXHR=window.XMLHttpRequest;` +
`function ProxyXHR(){this._h={};this._m='GET';this._u='';this.readyState=0;this.status=0;this.response=null;this.responseText='';this.responseType=''}` +
`ProxyXHR.prototype.open=function(m,u){this._m=m;this._u=u;this.readyState=1;this._fire('onreadystatechange')};` +
`ProxyXHR.prototype.setRequestHeader=function(k,v){this._h[k]=v};` +
`ProxyXHR.prototype._fire=function(n){try{if(this[n])this[n]()}catch(e){}};` +
`ProxyXHR.prototype.send=function(body){` +
`var self=this;` +
`rpc(String(this._m).toUpperCase(),abs(this._u),this._h,body?String(body):'').then(function(res){` +
`self.status=(res&&res.status)||200;self.readyState=4;self.responseText=decode(res);` +
`if(self.responseType==='json'){try{self.response=JSON.parse(self.responseText)}catch(e){self.response=null}}else{self.response=self.responseText}` +
`self._fire('onreadystatechange');self._fire('onload')` +
`},function(){self.status=0;self.readyState=4;self._fire('onreadystatechange');self._fire('onerror')})};` +
`ProxyXHR.prototype.getResponseHeader=function(){return null};` +
`ProxyXHR.prototype.getAllResponseHeaders=function(){return ''};` +
`ProxyXHR.prototype.abort=function(){};` +
`window.XMLHttpRequest=ProxyXHR;` +
`})();</script>`
// injectBrowserBridge 把回传/代理脚本插进页面 <head>(没有 head 就插在最前面),
// 并把书源页面的基地址与 Cookie 填进脚本:前者供相对地址解析,后者供页面判断
// 登录态(真实 Cookie 在服务端,页面看不到)。
func injectBrowserBridge(html, baseURL, cookie string) string {
if strings.Contains(html, `data-mebox-browser-bridge`) {
return html
}
script := strings.ReplaceAll(browserBridgeScript, "__MEBOX_BASE_URL__", jsStringEscape(baseURL))
script = strings.ReplaceAll(script, "__MEBOX_COOKIE__", jsStringEscape(cookie))
lower := strings.ToLower(html)
if i := strings.Index(lower, "<head>"); i >= 0 {
pos := i + len("<head>")
return html[:pos] + script + html[pos:]
}
if i := strings.Index(lower, "<html"); i >= 0 {
if j := strings.Index(lower[i:], ">"); j >= 0 {
pos := i + j + 1
return html[:pos] + script + html[pos:]
}
}
return script + html
}
// jsStringEscape 转义要嵌进 JS 模板字符串的地址(反引号 / 反斜杠 / ${)。
func jsStringEscape(s string) string {
r := strings.NewReplacer("\\", "\\\\", "`", "\\`", "${", "\\${")
return r.Replace(s)
}
// prepareBrowserPage 得到 iframe 要承载的页面源码。
//
// 返回的页面里会注入回传/代理脚本;脚本需要两样东西:
// - 基地址:解析页面里的相对地址(书源自带的 data: 页面没有真实地址,
// 退化成书源站点地址——书源自己的 BaseUrl() 也指向它);
// - Cookie:页面常用 document.cookie 判断登录态,而真实 Cookie 在服务端,
// 不预置的话「用户后台」会以为未登录并把浏览器导到 /login。
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
}
if html := rule.ParseDataHTML(req.URL); html != "" {
return injectBrowserBridge(html, sourceURL, s.browserCookieHeader(ctx, sourceURL, sourceURL)), req.URL, nil
}
// 2) 外部地址:服务端带书源 Cookie 抓取,并把资源地址改写到代理
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, 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>,至少让用户看到内容
wrapped := "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>" + htmlEscape(req.Title) +
"</title></head><body><pre style=\"white-space:pre-wrap;word-break:break-all;padding:16px;" +
"font:13px/1.6 ui-monospace,monospace\">" + htmlEscape(body) + "</pre></body></html>"
return injectBrowserBridge(wrapped, finalURL, cookie), finalURL, nil
}
return injectBrowserBridge(s.rewriteBrowserHTML(body, finalURL, id), finalURL, cookie), finalURL, nil
}
// browserCookieHeader 取书源在目标站点上的 Cookie 串(页面里预置 document.cookie 用)。
func (s *ReaderService) browserCookieHeader(ctx context.Context, sourceURL, target string) string {
if s.repo == nil || sourceURL == "" {
return ""
}
return s.newSourceState(ctx, sourceURL).CookieForRequest(target)
}
// fetchBrowserPage 服务端抓取页面(附带书源 Cookie / 登录请求头 / 书源请求头)。
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
}
return string(data), finalURL, contentType, nil
}
// FetchBrowserAsset 代理拉取页面资源(带书源 Cookie/请求头),供 iframe 内引用。
// 返回 (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 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)
if err != nil {
return "", 0, nil, "", err
}
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
httpReq.Header.Set(k, v)
}
if ck := state.CookieForRequest(target); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
// 书源级请求头(含 Referer / UA),与阅读请求保持一致
if s.repo != nil {
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, sourceURL); findErr == nil && src != nil && src.Header != "" {
var headers map[string]any
if json.Unmarshal([]byte(src.Header), &headers) == nil {
for k, v := range headers {
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
}
// Accept-Encoding 交给 net/http 管:显式设置会让它放弃自动解压,
// 压缩过的页面/资源就会以原始字节回到 iframe(页面直接白屏或乱码)。
helper.StripAcceptEncoding(httpReq.Header)
resp, err := s.http.Do(httpReq)
if err != nil {
return "", 0, nil, "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, browserMaxPageBytes))
if err != nil {
return "", 0, nil, "", err
}
data = helper.DecompressBody(resp, data)
finalURL := target
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"
}
return contentType, resp.StatusCode, data, finalURL, nil
}
// BrowserXHRResult 页面内 fetch/XHR 经服务端转发后的响应。
type BrowserXHRResult struct {
Status int `json:"status"`
ContentType string `json:"content_type"`
Body string `json:"body"`
// Base64 为真时 Body 是 base64(二进制资源)。
Base64 bool `json:"base64"`
}
// browserXHRMaxBytes 转发接口响应的大小上限。
const browserXHRMaxBytes = 4 << 20
// ProxyBrowserXHR 以书源身份转发页面内的接口请求。
//
// 承载页面是不透明源,页面自己的 XHR 既带不上书源 Cookie 也会被 CORS 拦掉,
// 所以由父窗口把请求转交进来,这里补上书源凭据再发出去。
// 返回的 HTTP 状态/内容类型原样回给页面,让页面自己的逻辑能正常分支。
func (s *ReaderService) ProxyBrowserXHR(ctx context.Context, id, method, target string, headers map[string]string, body string) (*BrowserXHRResult, error) {
entry := s.lookupBrowser(id)
if entry == nil {
return nil, errors.New("页面已过期,请重新打开")
}
if !strings.HasPrefix(target, "http://") && !strings.HasPrefix(target, "https://") {
return nil, fmt.Errorf("仅支持 http(s) 地址: %s", truncateForLog(target, 120))
}
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 {
return nil, err
}
// 先放页面自己声明的头,再用书源凭据补缺(Cookie 始终以服务端为准)
for k, v := range headers {
if strings.EqualFold(k, "cookie") || strings.EqualFold(k, "host") ||
strings.EqualFold(k, "content-length") {
continue
}
httpReq.Header.Set(k, v)
}
state := s.newSourceState(reqCtx, entry.sourceURL)
for k, v := range state.LoginHeaderMap() {
if strings.EqualFold(k, "cookie") {
continue
}
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, v)
}
}
if httpReq.Header.Get("Cookie") == "" {
if ck := state.CookieForRequest(target); ck != "" {
httpReq.Header.Set("Cookie", ck)
}
}
if s.repo != nil {
if src, findErr := s.repo.GetSourceAnyByURL(reqCtx, entry.sourceURL); findErr == nil && src != nil && src.Header != "" {
var extra map[string]any
if json.Unmarshal([]byte(src.Header), &extra) == nil {
for k, v := range extra {
if httpReq.Header.Get(k) == "" {
httpReq.Header.Set(k, fmt.Sprintf("%v", v))
}
}
}
}
}
helper.StripAcceptEncoding(httpReq.Header)
resp, err := s.http.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, browserXHRMaxBytes))
if err != nil {
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"
}
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
}
return out, nil
}
// isTextualContent 判断响应是否可以直接当字符串交给页面。
func isTextualContent(contentType string) bool {
ct := strings.ToLower(contentType)
for _, p := range []string{"text/", "json", "javascript", "xml", "html", "x-www-form-urlencoded", "csv"} {
if strings.Contains(ct, p) {
return true
}
}
return false
}
// ─── 资源改写与代理 ────────────────────────────────────────────────────────
// cssURLRe 匹配 CSS 里的 url(...) 引用。
// Go 的 regexp 不支持反向引用,因此引号用可选的成对字符类近似匹配。
var cssURLRe = regexp.MustCompile(`url\(\s*['"]?([^'")]+)['"]?\s*\)`)
// rewriteBrowserHTML 把页面里的资源/表单/站内链接地址改写到同源代理,
// 使页面在 iframe 里的后续请求同样带上书源 Cookie。
func (s *ReaderService) rewriteBrowserHTML(body, baseURL, id string) string {
base, err := url.Parse(baseURL)
if err != nil {
return body
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err != nil {
return body
}
rewrite := func(sel, attr string) {
doc.Find(sel).Each(func(_ int, node *goquery.Selection) {
val, ok := node.Attr(attr)
if !ok || strings.TrimSpace(val) == "" {
return
}
if abs := absoluteBrowserURL(base, val); abs != "" {
node.SetAttr(attr, s.browserAssetProxyURL(id, abs))
}
})
}
rewrite("img[src]", "src")
rewrite("script[src]", "src")
rewrite("link[href]", "href")
rewrite("iframe[src]", "src")
rewrite("source[src]", "src")
rewrite("video[src]", "src")
rewrite("audio[src]", "src")
// 表单提交也走代理,避免 POST 丢掉 Cookie
rewrite("form[action]", "action")
// 站内链接走代理;站外链接保留(用户可能确实想出去)
doc.Find("a[href]").Each(func(_ int, node *goquery.Selection) {
val, ok := node.Attr("href")
if !ok {
return
}
abs := absoluteBrowserURL(base, val)
if abs == "" || !sameSite(abs, baseURL) {
return
}
node.SetAttr("href", s.browserAssetProxyURL(id, abs))
})
// 内联样式里的 url(...)
doc.Find("[style]").Each(func(_ int, node *goquery.Selection) {
style, ok := node.Attr("style")
if !ok || !strings.Contains(style, "url(") {
return
}
node.SetAttr("style", rewriteCSSURLs(style, base, func(abs string) string {
return s.browserAssetProxyURL(id, abs)
}))
})
out, err := doc.Html()
if err != nil {
return body
}
return "<!DOCTYPE html>" + out
}
// RewriteBrowserCSS 改写 CSS 文本里的 url(...) 引用(资源代理用)。
func (s *ReaderService) RewriteBrowserCSS(css, baseURL, id string) string {
base, err := url.Parse(baseURL)
if err != nil {
return css
}
return rewriteCSSURLs(css, base, func(abs string) string {
return s.browserAssetProxyURL(id, abs)
})
}
func rewriteCSSURLs(css string, base *url.URL, proxy func(abs string) string) string {
return cssURLRe.ReplaceAllStringFunc(css, func(m string) string {
sub := cssURLRe.FindStringSubmatch(m)
if len(sub) < 2 {
return m
}
abs := absoluteBrowserURL(base, sub[1])
if abs == "" {
return m
}
return `url("` + proxy(abs) + `")`
})
}
// absoluteBrowserURL 把页面里的相对地址解析成绝对地址;不可代理的协议返回空串。
func absoluteBrowserURL(base *url.URL, ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" || strings.HasPrefix(ref, "#") {
return ""
}
lower := strings.ToLower(ref)
for _, p := range []string{"data:", "javascript:", "mailto:", "tel:", "blob:", "about:", "ws:", "wss:"} {
if strings.HasPrefix(lower, p) {
return ""
}
}
parsed, err := url.Parse(ref)
if err != nil {
return ""
}
abs := base.ResolveReference(parsed).String()
if !strings.HasPrefix(abs, "http://") && !strings.HasPrefix(abs, "https://") {
return ""
}
return abs
}
// sameSite 判断两个地址是否同站(eTLD+1)。
func sameSite(a, b string) bool {
return rule.CookieDomain(a) == rule.CookieDomain(b)
}
// ─── 签名与地址 ────────────────────────────────────────────────────────────
// browserSign 用 JWT 密钥做 HMAC,绑定待办 ID(页面)或 ID+目标地址(资源)。
func (s *ReaderService) browserSign(payload string) string {
mac := hmac.New(sha256.New, []byte(s.cfg.Secrets.JWTSecret))
mac.Write([]byte(payload))
return hex.EncodeToString(mac.Sum(nil))[:32]
}
func (s *ReaderService) browserPageSig(id string) string { return s.browserSign("page|" + id) }
func (s *ReaderService) browserAssetSig(id, target string) string {
return s.browserSign("asset|" + id + "|" + target)
}
// browserPageURL 生成 iframe 承载地址。
//
// 承载页面常常靠 location.search 取值:光遇聚合的段评页就是从 ?item_id&para&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 页面内引用用)。
func (s *ReaderService) browserAssetProxyURL(id, target string) string {
return "/api/reader/browser/asset?id=" + url.QueryEscape(id) +
"&s=" + s.browserAssetSig(id, target) +
"&u=" + base64.RawURLEncoding.EncodeToString([]byte(target))
}
// VerifyBrowserPage 校验页面承载签名,返回待办快照。
func (s *ReaderService) VerifyBrowserPage(id, sig string) (*BrowserPageSnapshot, error) {
if !hmac.Equal([]byte(s.browserPageSig(id)), []byte(sig)) {
return nil, errors.New("页面地址签名校验失败")
}
entry := s.lookupBrowser(id)
if entry == nil {
return nil, errors.New("页面已过期,请重新打开")
}
return &BrowserPageSnapshot{
ID: entry.id,
HTML: entry.html,
SourceID: entry.sourceID,
FinalURL: entry.finalURL,
}, nil
}
// VerifyBrowserAsset 校验资源代理签名并还原目标地址,返回(目标地址, 书源 URL)。
func (s *ReaderService) VerifyBrowserAsset(id, encoded, sig string) (string, string, error) {
raw, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", "", errors.New("资源地址解码失败")
}
target := string(raw)
if !hmac.Equal([]byte(s.browserAssetSig(id, target)), []byte(sig)) {
return "", "", errors.New("资源地址签名校验失败")
}
entry := s.lookupBrowser(id)
if entry == nil {
return "", "", errors.New("页面已过期,请重新打开")
}
return target, entry.sourceURL, nil
}
// ─── 待办表 ────────────────────────────────────────────────────────────────
// PendingBrowserPages 返回该用户在某书源下待用户完成的页面(前端轮询)。
// 含 open 模式:startBrowser 只展示不回传,同样需要前端把页面呈现出来。
func (s *ReaderService) PendingBrowserPages(userID, sourceID string) []BrowserPage {
s.browserMu.Lock()
defer s.browserMu.Unlock()
s.pruneBrowsersLocked()
out := make([]BrowserPage, 0, len(s.browserPending))
for _, e := range s.browserPending {
if e.sourceID != sourceID {
continue
}
// 已交付/已取消的页面不再返回:否则前端下一次轮询会把它当成新页面弹出来。
if e.finished {
continue
}
// 待办是短生命周期对象;未标注用户的老调用路径一律放行。
if e.userID != "" && userID != "" && e.userID != userID {
continue
}
out = append(out, s.pageOfLocked(e))
}
return out
}
// 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()
if entry.finished {
entry.mu.Unlock()
return errors.New("该页面已完成")
}
if cancelled {
entry.err = rule.ErrBrowserCancelled
} else {
resultURL := strings.TrimSpace(finalURL)
if resultURL == "" {
resultURL = entry.finalURL
}
entry.result = rule.BrowserResult{URL: resultURL, Body: body}
}
entry.finished = true
if entry.done != nil {
close(entry.done)
}
entry.mu.Unlock()
s.dropBrowser(id)
return nil
}
func (s *ReaderService) lookupBrowser(id string) *pendingBrowser {
s.browserMu.Lock()
defer s.browserMu.Unlock()
s.pruneBrowsersLocked()
return s.browserPending[id]
}
func (s *ReaderService) dropBrowser(id string) {
s.browserMu.Lock()
defer s.browserMu.Unlock()
delete(s.browserPending, id)
}
// pruneBrowsersLocked 清掉过期条目并唤醒仍在等待的调用方(避免泄漏协程)。
func (s *ReaderService) pruneBrowsersLocked() {
now := time.Now()
for id, e := range s.browserPending {
if now.Before(e.expires) {
continue
}
e.mu.Lock()
if !e.finished {
e.finished = true
e.err = errors.New("页面等待超时")
if e.done != nil {
close(e.done)
}
}
e.mu.Unlock()
delete(s.browserPending, id)
}
}
// 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
if strings.HasPrefix(target, "data:") || len(target) > 512 {
target = ""
}
return BrowserPage{
ID: e.id,
Title: e.request.Title,
Mode: e.mode,
Seq: e.seq,
PageURL: s.browserPageURL(e.id, e.request.URL),
Refetch: e.request.Refetch,
SourceID: e.sourceID,
TargetURL: target,
}
}
// ─── 小工具 ────────────────────────────────────────────────────────────────
func newBrowserID() string {
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
return fmt.Sprintf("%d", time.Now().UnixNano())
}
return hex.EncodeToString(buf)
}
func htmlEscape(s string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;").Replace(s)
}
func truncateForLog(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
@@ -0,0 +1,480 @@
package reader
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/truewhile/MeBox/internal/service/reader/rule"
)
// 本文件:书源宿主浏览器面板的服务层测试(browser_panel.go)。
//
// 覆盖真实书源依赖的三件事:
// 1. startBrowserAwait 登记待办 → 前端轮询到 → 回传 DOM → 阻塞解除;
// 2. http(s) 页面由服务端带书源 Cookie 抓取(浏览器里没有这些 Cookie);
// 3. 页面里的资源地址被改写到同源代理,且签名校验拦得住伪造。
// browserPanelSourceJSON 构造一个内联书源:登录面板有「切换线路」按钮,
// 其实现与光遇聚合的 getServerSettings 同构——打开内嵌 HTML,回传 DOM 后
// 从 DOM 里解析线路写进源变量。
func browserPanelSourceJSON(t *testing.T, sourceURL string) string {
t.Helper()
loginJS := `function switchLine() {
let html = '<!DOCTYPE html><html><body><span id="serverValue">线路甲</span></body></html>';
let body = java.startBrowserAwait('data:text/html;base64,' + java.base64Encode(html), '线路设置', false).body();
let m = body.match(/id="serverValue"\s*>\s*([^<]*?)\s*<\/span>/);
source.setVariable(JSON.stringify({线路: m ? m[1] : ''}));
return m ? m[1] : '';
}`
src := map[string]any{
"bookSourceUrl": sourceURL,
"bookSourceName": "浏览器面板测试源",
"loginUrl": loginJS,
"loginUi": `[{"name":"切换线路","type":"button","action":"switchLine()"}]`,
}
out, err := json.Marshal(src)
if err != nil {
t.Fatal(err)
}
return string(out)
}
// querySig 从签名地址里取出 s 参数。
func querySig(t *testing.T, raw string) string {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("承载地址不合法: %q", raw)
}
return u.Query().Get("s")
}
// waitPending 轮询等待出现一个待办页面。
func waitPending(t *testing.T, svc *ReaderService, sourceID string) BrowserPage {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
pages := svc.PendingBrowserPages(readerTestUserID, sourceID)
if len(pages) > 0 {
return pages[0]
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("等待超时:未出现待用户完成的页面")
return BrowserPage{}
}
// TestBrowserPanelSwitchLineRoundTrip 端到端:书源阻塞 → 前端拿到页面 →
// 回传 DOM → 书源把线路写进源变量。
func TestBrowserPanelSwitchLineRoundTrip(t *testing.T) {
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
type actionResult struct {
res *LoginResult
err error
}
done := make(chan actionResult, 1)
go func() {
res, err := svc.RunLoginAction(ctx, readerTestUserID, sourceID, "switchLine()", nil)
done <- actionResult{res: res, err: err}
}()
page := waitPending(t, svc, sourceID)
if page.Mode != browserModeWait {
t.Fatalf("模式 = %q,期望 wait", page.Mode)
}
if page.Title != "线路设置" {
t.Fatalf("标题 = %q", page.Title)
}
// data: 地址不应回传给前端展示(又长又没用)
if page.TargetURL != "" {
t.Fatalf("data: 地址不应回传: %q", page.TargetURL)
}
// 承载页面的签名必须有效,且内容是书源拼的那段 HTML
snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL))
if err != nil {
t.Fatalf("承载页面校验失败: %v", err)
}
if !strings.Contains(snap.HTML, `id="serverValue"`) {
t.Fatalf("承载页面内容异常: %q", snap.HTML)
}
// 伪造签名必须被拒
if _, err := svc.VerifyBrowserPage(page.ID, "deadbeef"); err == nil {
t.Fatal("伪造签名不应通过校验")
}
// 模拟用户在页面里改选了「线路乙」后点 √:回传操作后的 DOM
if err := svc.ResolveBrowser(page.ID, readerTestUserID,
`<html><body><span id="serverValue">线路乙</span></body></html>`, "", false); err != nil {
t.Fatalf("回传结果失败: %v", err)
}
select {
case r := <-done:
if r.err != nil {
t.Fatalf("登录动作失败: %v", r.err)
}
if !r.res.OK {
t.Fatalf("动作未成功: %+v", r.res)
}
case <-time.After(5 * time.Second):
t.Fatal("回传后阻塞未解除")
}
// 书源应已把线路写进源变量(真正切换生效)
st := svc.newSourceState(ctx, "https://panel.example.com")
if !strings.Contains(st.GetVariable(), "线路乙") {
t.Fatalf("线路未写入源变量: %q", st.GetVariable())
}
// 待办应被清理
if pages := svc.PendingBrowserPages(readerTestUserID, sourceID); len(pages) != 0 {
t.Fatalf("完成后待办未清理: %+v", pages)
}
}
// TestBrowserPanelCancelReleasesBlock 取消页面时要解除阻塞(书源走降级分支)。
func TestBrowserPanelCancelReleasesBlock(t *testing.T) {
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
done := make(chan error, 1)
go func() {
// action 为空表示执行 login();这里用 switchLine,取消后书源会抛异常
_, err := svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil)
done <- err
}()
page := waitPending(t, svc, sourceID)
if err := svc.ResolveBrowser(page.ID, readerTestUserID, "", "", true); err != nil {
t.Fatalf("取消失败: %v", err)
}
select {
case err := <-done:
// 书源未 catch,异常应回到调用方;关键是「不能一直卡住」
if err == nil {
t.Log("取消后动作用空 body 继续执行(书源自行降级)")
}
case <-time.After(5 * time.Second):
t.Fatal("取消后阻塞未解除")
}
}
// 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&para=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) {
var userCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
userCookie = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if !strings.Contains(userCookie, "qttoken=") {
_, _ = w.Write([]byte(`<html><body>请先登录</body></html>`))
return
}
_, _ = w.Write([]byte(`<html><body><h1>我的账号</h1>` +
`<img src="/avatar.png"><a href="/orders">订单</a>` +
`<a href="https://other-site.net/x">站外</a></body></html>`))
case "/avatar.png":
_, _ = w.Write([]byte("PNGDATA"))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL))
// 先造出登录态(等价于书源已登录成功)
st := svc.newSourceState(ctx, srv.URL)
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
st.flush()
// 直接驱动服务层:等价于书源调用 startBrowser 打开用户后台
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
URL: srv.URL + "/user", Title: "用户后台",
}); err != nil {
t.Fatalf("打开用户后台失败: %v", err)
}
if !strings.Contains(userCookie, "qttoken=SESSION_abcdef123456") {
t.Fatalf("抓取页面时未带上书源 Cookie: %q", userCookie)
}
page := waitPending(t, svc, sourceID)
if page.Mode != browserModeOpen {
t.Fatalf("模式 = %q,期望 open", page.Mode)
}
if page.TargetURL != srv.URL+"/user" {
t.Fatalf("原始地址 = %q", page.TargetURL)
}
snap, err := svc.VerifyBrowserPage(page.ID, querySig(t, page.PageURL))
if err != nil {
t.Fatal(err)
}
// 已登录内容(证明用的是服务端 Cookie 抓到的页面)
if !strings.Contains(snap.HTML, "我的账号") {
t.Fatalf("页面不是登录态内容: %q", snap.HTML)
}
// 资源地址应被改写到同源代理;站外链接保留原样
if !strings.Contains(snap.HTML, "/api/reader/browser/asset?") {
t.Fatalf("资源地址未改写: %q", snap.HTML)
}
if !strings.Contains(snap.HTML, "https://other-site.net/x") {
t.Fatalf("站外链接不应改写: %q", snap.HTML)
}
// 注入脚本要带齐三样:DOM 回传、请求代理、以及书源登录态
if !strings.Contains(snap.HTML, "data-mebox-browser-bridge") {
t.Fatalf("缺少注入脚本: %q", snap.HTML)
}
if !strings.Contains(snap.HTML, "__mebox_proxy__") {
t.Fatal("注入脚本缺少请求代理(页面自己的 fetch/XHR 会因不透明源被 CORS 拦)")
}
// 页面常用 document.cookie 判断登录态;真实 Cookie 在服务端,
// 不预置的话「用户后台」会以为未登录并把浏览器导去 /login
if !strings.Contains(snap.HTML, "qttoken=SESSION_abcdef123456") {
t.Fatalf("注入脚本未预置书源 Cookie: %q", snap.HTML)
}
// 相对地址要按书源页面地址解析(沙箱里基地址不是承载地址)
if !strings.Contains(snap.HTML, srv.URL) {
t.Fatalf("注入脚本未带上页面基地址: %q", snap.HTML)
}
// 资源代理要能校验签名并把 Cookie 带上
assetURL := extractAssetURL(t, snap.HTML)
u, err := url.Parse(assetURL)
if err != nil {
t.Fatal(err)
}
target, gotSourceURL, err := svc.VerifyBrowserAsset(
u.Query().Get("id"), u.Query().Get("u"), u.Query().Get("s"))
if err != nil {
t.Fatalf("资源签名校验失败: %v", err)
}
if !strings.HasSuffix(target, "/avatar.png") {
t.Fatalf("资源目标 = %q", target)
}
if gotSourceURL != srv.URL {
t.Fatalf("资源关联的书源 = %q", gotSourceURL)
}
contentType, status, data, err := svc.FetchBrowserAsset(ctx, page.ID, target)
if err != nil {
t.Fatalf("拉取资源失败: %v", err)
}
if status != http.StatusOK || string(data) != "PNGDATA" {
t.Fatalf("资源内容异常: status=%d body=%q", status, data)
}
if contentType == "" {
t.Fatal("资源缺少 Content-Type")
}
// 伪造资源签名必须被拒
if _, _, err := svc.VerifyBrowserAsset(u.Query().Get("id"), u.Query().Get("u"), "deadbeef"); err == nil {
t.Fatal("伪造资源签名不应通过校验")
}
}
// TestBrowserPanelXHRProxy 页面内的接口请求要由服务端代发并补上书源 Cookie。
// 「用户后台」这类页面靠接口取数,iframe 自己的 XHR 带不上 Cookie 也会被 CORS 拦。
func TestBrowserPanelXHRProxy(t *testing.T) {
var apiCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/user":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<html><head></head><body>后台</body></html>`))
case "/api/me":
apiCookie = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if !strings.Contains(apiCookie, "qttoken=") {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"未登录"}`))
return
}
_, _ = w.Write([]byte(`{"nickname":"tester","vip":true}`))
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
svc, _ := newLoginTestService(t)
ctx := t.Context()
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, srv.URL))
st := svc.newSourceState(ctx, srv.URL)
st.SetCookie(srv.URL, "qttoken=SESSION_abcdef123456")
st.flush()
if err := svc.openBrowser(ctx, srv.URL, sourceID, readerTestUserID, browserCookieTarget{}, rule.BrowserTask{
URL: srv.URL + "/user", Title: "用户后台",
}); err != nil {
t.Fatalf("打开用户后台失败: %v", err)
}
page := waitPending(t, svc, sourceID)
res, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, srv.URL+"/api/me",
map[string]string{"X-From-Page": "1"}, "")
if err != nil {
t.Fatalf("转发请求失败: %v", err)
}
if res.Status != http.StatusOK {
t.Fatalf("状态 = %d body=%q", res.Status, res.Body)
}
if !strings.Contains(apiCookie, "qttoken=SESSION_abcdef123456") {
t.Fatalf("转发时未带上书源 Cookie: %q", apiCookie)
}
if !strings.Contains(res.Body, `"nickname":"tester"`) {
t.Fatalf("响应体异常: %q", res.Body)
}
if !strings.Contains(res.ContentType, "json") {
t.Fatalf("内容类型 = %q", res.ContentType)
}
if res.Base64 {
t.Fatal("JSON 响应不应走 base64")
}
// 未知待办:拒绝(链接已过期)
if _, err := svc.ProxyBrowserXHR(ctx, "nope", http.MethodGet, srv.URL+"/api/me", nil, ""); err == nil {
t.Fatal("未知待办应报错")
}
// 非 http(s):拒绝,避免被当成任意协议跳板
if _, err := svc.ProxyBrowserXHR(ctx, page.ID, http.MethodGet, "file:///etc/passwd", nil, ""); err == nil {
t.Fatal("非 http(s) 地址应报错")
}
}
// extractAssetURL 从改写后的页面里取出第一个资源代理地址。
func extractAssetURL(t *testing.T, html string) string {
t.Helper()
idx := strings.Index(html, "/api/reader/browser/asset?")
if idx < 0 {
t.Fatalf("页面里没有资源代理地址: %q", html)
}
rest := html[idx:]
// 到引号/尖括号为止
end := strings.IndexAny(rest, `"'< `)
if end < 0 {
end = len(rest)
}
return strings.ReplaceAll(rest[:end], "&amp;", "&")
}
// TestBrowserPanelUnknownSourceIsolated 待办按书源隔离,避免串台。
func TestBrowserPanelUnknownSourceIsolated(t *testing.T) {
svc, _ := newLoginTestService(t)
sourceID := prepareLoginSource(t, svc, browserPanelSourceJSON(t, "https://panel.example.com"))
done := make(chan struct{}, 1)
go func() {
_, _ = svc.RunLoginAction(t.Context(), readerTestUserID, sourceID, "switchLine()", nil)
done <- struct{}{}
}()
page := waitPending(t, svc, sourceID)
if pages := svc.PendingBrowserPages(readerTestUserID, "other-source"); len(pages) != 0 {
t.Fatalf("其它书源不应看到待办: %+v", pages)
}
// 收尾,避免 goroutine 挂到超时
_ = svc.ResolveBrowser(page.ID, readerTestUserID, "<span id=\"serverValue\">x</span>", "", false)
select {
case <-done:
case <-time.After(5 * time.Second):
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)
}
}
+107
View File
@@ -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)
}
}
}
+494
View File
@@ -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 实体。
// 苹果/源阅分支会把地址里的 & 转成 &amp;(paraForiOS),直接拿去请求会 404。
func trimCommentURL(raw string) string {
r := strings.NewReplacer(
"&quot;", `"`, "&#34;", `"`,
"&#39;", "'", "&apos;", "'",
"&lt;", "<", "&gt;", ">",
"&amp;", "&",
)
return strings.TrimSpace(r.Replace(raw))
}
// isBlankMarkup 判断一段文字去掉空块级标签/空白后是否为空。
func isBlankMarkup(s string) bool {
s = wrapMarkupRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "&nbsp;", " ")
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.GetSourceAnyByURL(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()
}

Some files were not shown because too many files have changed in this diff Show More