原先只认 .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/加密/超大条目)、 目录认领、原地编辑、只读约束、各格式的读取端点
11 KiB
架构与数据边界
本文记录为什么这样设计,尤其是那些一旦写错就很难回头的决策。 产品设计文档(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):
mtime + size:便宜,跳过绝大多数文件- 内容哈希:权威,确认字节真的变了
只有第一阶段判断"可能变了"才读文件算哈希。所以每 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,从不直接参与计算 —— 移动或重命名仍然走内容哈希规则, 所以进度照旧保留。