Files
reader/docs/architecture.md
T
cnb.dQC5J6ohAGA 664141f0f1 feat: 支持 txt 与漫画等多种格式,格式层改为注册表
原先只认 .epub 和 .pdf,本地书库里占很大比重的 txt 长篇与漫画
(cbz、图片目录)会整片「消失」—— 用户能看到文件在 NAS 上,却在
书架里找不到,这是最让人困惑的一类问题。

格式列表硬编码在三处(扫描器白名单、parseBookFile 分支、content-type
映射表),加一种格式要同时改三处,漏掉任何一处就得到一个「扫到了但读不
出来」的安静 bug。这次把格式收进注册表,并补齐四种格式。

新增格式
- cbz / zip:按包内页序自然排序翻页,扫描时不解压到磁盘
- 漫画目录:一个目录一本书,子目录为卷,支持卷/页两级寻址
- txt:UTF-8 严格解码失败才回退 GB18030(UTF-16 BOM 也识别),
  按中文网文章节标题分章,正文可按章节或字节偏移读取
- 单张图片按单页读物入库
- 明确不支持 cbr/rar:需要非自由解压实现,与单容器约束冲突

格式层
- registry.ts 定义 handler 契约(parse/manifest/asset)与扩展名路由,
  扫描器与 HTTP 层只与它对话;扩展名白名单、能力声明、遍历过滤都自动派生
- zip-reader.ts 自研只读 ZIP,只用 node:zlib。JsZip 会把整包解压进内存,
  而漫画动辄几百 MB,首次扫描真实漫画库会 OOM
- natural-sort.ts 自研自然排序:page10 必须排在 page2 之后,
  否则整本漫画的阅读顺序是乱的;不用 localeCompare 是因为 ICU 行为
  在不同平台上不一致
- epub 章节返回前重写章节内的相对资源引用,否则 WebView 里图片/字体/
  样式全部 404;绝对 URL、data: URI 与文内锚点保持原样,脚注不会坏

修复
- 目录型读物认领后,其内部文件从索引中排除,否则同一部漫画会以
  「系列 + 每卷 + 每页」的形式重复出现
- 目录认领顺序改为从外往里:反过来的话一部漫画会变成 N 本独立卷
- 主键新增原地编辑规则:txt/漫画没有内嵌标识,追加一章会算出新 id
  并把读者进度留在旧记录上。现在同路径原地编辑算更新,移动/重命名
  仍走内容哈希规则,所以进度都保得住
- 损坏的 .cbz 仍然入库并记录失败原因,而不是静默从书架消失
- PDF 不再用正则猜页数(会匹配到无关的 /Count 并报出错误页数,
  让客户端分页错乱),改为诚实上报 null
- 清理已迁出的 parseBookFile / pdfInfo / SUPPORTED_EXTENSIONS

测试
- 新增 65 个用例(共 92 个):真实 zip 与真实文件,不用 mock;
  fixtures 由测试自己生成,不需要签入二进制
- 覆盖自然排序、编码探测、ZIP 边界(ZIP64/加密/超大条目)、
  目录认领、原地编辑、只读约束、各格式的读取端点
2026-09-16 14:31:15 +08:00

11 KiB
Raw Blame History

架构与数据边界

本文记录为什么这样设计,尤其是那些一旦写错就很难回头的决策。 产品设计文档(v0.2)里的约束在这里落成了实现,逐条对应。


1. 数据边界

位置 内容 读写
BOOKS_DIR (/books) 用户自己的电子书 只读
DATA_DIR (/data) SQLite、封面缓存、令牌密钥 读写
客户端本地 离线书文件缓存、本地进度 读写

服务端是唯一真相源。客户端只承担「渲染 + 缓存」。

只读挂载的强制手段

src/config/index.ts 在启动时断言 DATA_DIR 不在 BOOKS_DIR 内,否则拒绝启动。 这不是文档里的君子协定,是运行时会拦下来的硬约束。

扫描器与所有文件访问都经过 src/lib/paths.ts 的 resolveInside()—— 唯一的路径解析出口,../ 逃逸会抛 PATH_TRAVERSAL。 扫描时也不跟随符号链接:指向挂载点外的软链会读到用户没打算共享的文件。

封面缓存写在 DATA_DIR/covers/,不写在书旁边。


2. 书籍主键

id = sha256( (dc:identifier ?? '').toLowerCase() + '\0' + sha256(fileBytes) )

为什么不用文件路径

用户的真实行为是持续整理书库:改名、分目录、加前缀。

场景 路径做主键 当前设计
重命名 三体.epub → 刘慈欣 - 三体 (修订版).epub 进度全丢 进度保留
移动到另一个目录 进度全丢 进度保留
同一本书放两份 书架出现两条 合并成一条
重新编码的同名文件 误认为同一本 视为不同副本

第二列是自建书库产品最常见的差评来源,所以这条必须在上线前定死。

为什么哈希也要参与

只用 dc:identifier 不行:很多自制/盗版 EPUB 没有 identifier,或者多本书共用 一个占位 identifier。只用哈希也不行:同一本书不同来源的副本哈希不同,无法合并。

两者结合:identifier 提供稳定的锚点,哈希保证「字节不同就是不同的副本」。 identifier 缺失时退化为纯哈希,行为仍然正确(只是无法跨副本合并)。

文件表与书表的分离

book_files 记录磁盘上的文件(rel_path、size、mtime),books 记录书。 多对一:一本书可以有多份副本。

文件被删时 missing=1,不立即删记录——用户可能只是临时拔了移动硬盘。 但一旦这本书已经有了别的可用文件,旧的行就是纯噪声,会被清掉, 否则反复整理书库会让文件表无限增长。

只有「没有任何可用文件」且「用户没手动加进书架」的书才会被删除。 宁可显示一本暂时不可用的书,也不能删掉读者精心整理过的书架条目。


3. 增量扫描

两阶段变更检测(Scanner.indexFile):

  1. mtime + size:便宜,跳过绝大多数文件
  2. 内容哈希:权威,确认字节真的变了

只有第一阶段判断"可能变了"才读文件算哈希。所以每 60 秒轮询一次也很轻。

Scanner.scan() 对并发调用做去重(共享同一个 Promise), 避免定时器和手动触发同时砸磁盘。


4. 元数据优先级链

1. 手动修改(metadata_overrides 表)
2. EPUB 内嵌(dc:* + calibre meta)
3. provider 补丁(只填空缺)
4. 文件名解析

关键实现细节

books 表存的是第 2/3/4 层的结果。第 1 层单独一张表,读取时叠加 (ShelfService.toDto → applyOverrides)。

这样做的原因:扫描器可以放心地刷新 books 表,因为手动修改根本不在那张表里, 不可能被覆盖。

books.source 记录的是基础值的来源(embedded / filename / provider:x), 不记录 manual。手动修改过的字段由 DTO 的 manualFields 单独返回。 保持来源干净,撤销单个字段时才能正确回落到原始值。

文件名解析的保守策略

中文电子书里 书名 - 作者 和 作者 - 书名 都极常见,没有标记时无法区分。

因此只在有证据时才切分:

  • 《书名》 → 这一侧是书名
  • 著 / 编 / 译 → 这一侧是作者
  • 一侧是中文人名(2-4 字且首字为常见姓氏)→ 另一侧是书名
  • 一侧是拉丁文人名 → 另一侧是书名
  • 都不满足 → 整串保留为书名,作者留空

最后一条是刻意的:漏一个作者,读者手动补一下就好;猜错一个作者, 书就被归到错误的人名下,而且他未必会发现。

其余正则也踩过坑,值得记下来:

  • 系列正则 [【\[]...[】\]] 里 \[ 在字符类中不转义,会把分隔符 ' - ' 吃掉, 导致 金庸 - 射雕英雄传 变成 title=金庸, author=射雕英雄传。改成显式括号对匹配。
  • 「(第1卷)」这种卷标,书名是作品本身,不是「第」。要先剥离再切分作者, 且剥离要按完整括号组做,否则会留下 射雕英雄传(第1卷 这样的半截括号。

5. 同步与冲突合并

为什么是 last-writer-wins

一台设备属于一个读者。多设备之间不存在"编辑冲突",只存在"某台设备落后了"。 所以 LWW 是正确语义,不需要 CRDT 或向量时钟的复杂度。

墓碑

笔记删除写 deleted=1 而不是 DELETE。

否则:手机离线时删了一条笔记 → 平板(也离线,数据更旧)重连 → 把笔记推回来 → 已删除的笔记复活。墓碑让这个时序变得无害。

批次原子性

SyncService.push 整个批在一个事务里。断网不会留下写了一半的进度。

增量拉取

GET /api/v1/sync?since=<上次的 serverTime>。客户端记住上次返回的 serverTime, 每次只拿增量。移动网络下重连很便宜。


6. 认证与安全

  • scrypt 做口令哈希(内置模块,无原生依赖)。比较用 timingSafeEqual。
  • 登录时即便用户不存在也跑一次哈希比较,避免用时间差枚举用户名。
  • 访问令牌是 HS256 签名的无状态令牌;刷新令牌是不透明随机串存库,可按设备撤销。
  • 改口令 / 停用账号会撤销该用户所有会话。
  • 每个请求都从库里重读用户,不信任令牌里的 role——停用账号立即失效。
  • 刷新令牌轮转:用过即废,降低泄露窗口。

不要重新引入原生依赖

镜像构建的稳定性直接决定部署成功率,而部署门槛是最大的获客阻力。 better-sqlite3、argon2、bcrypt 都需要构建时的 make/gcc, 在部分 NAS 环境会直接装不上。

node:sqlite 和 node:crypto 的 scrypt 都是内置的,所以当前依赖树里 没有任何需要编译的包。


7. 向后兼容承诺

自部署用户不会及时升级。所以:

  • API 只做加法:加字段、加端点。不改已有字段语义、不删端点。
  • 数据库 schema 用 CREATE TABLE IF NOT EXISTS,升级时新增列/表,不动旧的。
  • 错误响应格式全站统一,客户端可以一直用同一套解析逻辑。

8. 客户端渲染(尚未实现)

产品设计里的关键决策:不要直接套 Readium Mobile。

Readium 用 Readium CSS 主动接管排版、覆盖出版方样式,这与本产品 「忠实还原精排书设计」的核心差异化直接冲突。

计划方案(下一轮):

  • 原生外壳(Kotlin)只负责:文件与缓存、手势、书架、账号
  • 渲染层用 WebView + 自研 CSS 干预策略,参考 foliate-js 的「保留作者样式 + 最小覆盖」
  • 参考 readium-css 的变量组织方式,但不引入它的默认接管行为

服务端为此准备的是格式中立的清单契约:GET /api/v1/books/:id/items 返回 可寻址结构(章节 / 页 / 卷),assets?ref= 取单个资源。kind 字段告诉客户端 该按可重排文本还是固定页来渲染,所以 PDF、漫画和 EPUB 可以复用同一套客户端骨架。

格式层:为什么是注册表

早期版本把格式列表硬编码在三处:扫描器的扩展名白名单、parseBookFile 的分支、 以及 content-type 映射表。加一种格式要同时改三处,漏掉任何一处都会得到一个 「文件被扫到了但读不出来」的安静 bug。

现在这一切收在 src/indexer/formats/:

registry.ts   定义 handler 契约,按扩展名路由
index.ts      import 即注册;import 顺序即优先级
epub.ts  pdf.ts  comic-archive.ts  comic-directory.ts  text.ts  image.ts

扫描器和 HTTP 层只与 registry.ts 对话。新增格式 = 写一个 handler + 一行 import, 扩展名白名单、/library/formats 声明、文件遍历过滤都会自动跟上。

两种 handler

格式在结构上确实是两类,所以契约也分成两个:

  • 文件型:一个文件一本书(epub、pdf、cbz、txt、单图)
  • 目录型:一个目录一本书(图片目录、漫画卷)。它们没有自己的文件, 所以扫描器在文件遍历之前先发现它们,再把认领目录里的文件排除在索引之外。

目录认领顺序是从外往里(浅目录优先)。反过来的话,进击的巨人/第01卷/ 会各自成为一本书,读者要跨书架记录去找下一卷;而且同一批页面会以 「系列 + 每卷 + 每页」的形式重复出现。外层优先符合人手动归档的习惯: 外层目录是系列,子目录是卷。

各格式的取舍

ZIP 自己实现(zip-reader.ts,只用 node:zlib)。项目已有的 JSZip 会把整个 压缩包解压进内存,而漫画动辄几百 MB,扫描器又要遍历全库 —— 用 JSZip 会让首次扫描 真实漫画库时内存爆掉。自研版本只解析中央目录、按需解压单个条目。

GB18030 探测(text.ts)。中文 .txt 里 GBK/GB18030 占比极高,按 UTF-8 读会得到 一片替换字符且无法挽回。做法是先严格解码 UTF-8(非法字节序列直接抛错),失败才回退, 所以合法的 UTF-8 永远不会被误判成 GB18030。

PDF 不上报页数。旧实现用正则从页树里抠 /Count,很容易匹配到无关的 /Count 从而报出错误的页数 —— 客户端的分页和进度条会因此错乱。现在诚实地上报 null: 客户端打开文档后自己知道真实页数。猜一个数字比没有数字更糟。

epub 章节重写资源引用。章节 XHTML 里的 images/pic.png 是相对 OPF 目录的; 客户端是从资源端点取到这份文档的,原样交给 WebView 会让图片、字体、样式表全部 404。 服务端在返回前把这些相对引用改写成指向自己的绝对地址,同时跳过绝对 URL、 data: URI 和文内锚点 —— 改写脚注链接会把脚注弄坏。

书籍主键的补充规则

dc:identifier + 内容哈希 对 EPUB 够用,但对没有内嵌标识的格式(txt、漫画)不安全: 它们唯一能锚定的就是内容哈希,于是在原文件上追加一章会算出新的 id, 读者的进度就留在旧记录上了。这在 txt 长篇上尤其明显。

补的规则是:如果同一个路径上次已经解析出一个 id,且新旧两边的「标识锚」 相同(通常都是空),就沿用旧 id。也就是说原地编辑算更新,不算新书。 路径只用来查找上一个 id,从不直接参与计算 —— 移动或重命名仍然走内容哈希规则, 所以进度照旧保留。