Files
reader/docs/architecture.md
T
CodeBuddy 78bfe557c3 feat: 客户端 Android 与 H5 双端,含精排渲染层
按产品设计 §5「原生外壳 + WebView 渲染」落地客户端,Android 与浏览器共用同一份
渲染层;同时把服务端的格式范围从 epub + pdf 扩展到 txt 与漫画。

## 为什么两端共用一份渲染层

Android 端不重写渲染。它是一个 Kotlin 原生外壳,只负责文件缓存、手势、账号与书架,
渲染复用 web/ 构建出的同一份产物。原因很直接:排版策略一旦有第二份实现就一定会分叉,
而「忠于精排」正是这个产品的差异点。

共用的抽象在 web/src/core/platform.ts(传输、键值存储、二进制存储、连通性)。
外壳只覆盖 WebView 做不到或做不好的三件事:

- 连通性:WebView 里的 navigator.onLine 只要有网卡就报「在线」,连上没有出口的
  Wi-Fi 时会骗人,客户端于是转圈而不是读缓存。改用平台的 NET_CAPABILITY_VALIDATED。
- 稳定的设备名:UA 里的型号会随 Chrome 升级变化,作为「上次在哪个设备读的」标签不可靠。
- 原生提示:一行 Kotlin 的 Toast,不用在 Web 层造通知 UI。

跨语言的接缝(Kotlin 桥接 ↔ TypeScript 声明)没法互相类型检查,因此加了
android/scripts/check-shell.sh 做双向契约校验,并接进 CI。

## 精排 CSS 策略

这是整个项目最产品化的一块 CSS,所以规则是写明的而不是凑出来的。

三条原则:不设任何属性除非不设会坏;每个可调项都是默认 inherit 的可继承自定义属性
(「关」的意思是「不动它」,不是「用我们的默认值」);任何改变作者意图的行为都是
用户主动开启且持久化。

实际 override 只有七处,每处都在注释里写了为什么:图片/表格/pre 在窄屏会撑破布局、
ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。

被刻意删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。它们能让排版
「看起来一致」,而一致性恰恰是这里要避免的。

书的样式装进 shadow root。书里一条 p { color: red } 不能改到书架,App 的 .topbar
也不能被书撞上。用 iframe 也能隔离,但每次换章要重建文档,会丢滚动位置并在 Android
上多花几倍内存。关键是**不往 shadow root 里注入任何 reset**。

## 格式层

服务端只负责流式给出原始文件,拆包、编码、章节与分页都是渲染层的事——
这些恰好是产品要自己拿主意的部分。

- EPUB:OPF/spine/nav 解析,作者样式全部保留,相对引用改写成内部 reader-res: 协议,
  绝对 URL 原样保留交给 CSP 挡掉。加载器按文档逐个容错,一个坏章节不会毁掉整本书。
- TXT:先严格校验 UTF-8(自己实现,不用宽松模式——宽松模式永远「成功」,于是永远
  发现不了解错了),失败再依次试 GB18030 与 Big5,并把选择告诉用户、允许覆盖。
  章节只能从标题推断,策略是宁可少认不要错认:800 字的段落以「第三章」开头不算标题,
  正文里提到「他在第二章读过」也不算。两条都有测试固定。
- CBZ / 图片目录:自然序排页(1,2,10 而不是 1,10,2),一页一个 fixed-layout section。
  排错不是观感问题,是故事被静默打乱。
- 检测以魔数优先:本地库里 .txt 其实是 EPUB、.cbz 里其实是 PDF 的情况很多,
  按扩展名开错加载器只会得到一个看不懂的报错。

服务端扫描器相应扩展:txt/cbz 直接索引;图片目录按文件夹识别为一本书,
文件夹路径作为主键锚点,所以**往漫画文件夹加一页不会丢进度**——这条有测试。

## 客户端三态与离线队列

离线队列记的是**脏集合**(哪些记录还没送到服务端),而不是「当前全部状态」。
两者只在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前
被修改了。脏集合下这次更新仍是脏的,要等它自己的确认才清除;「全发」模式下它会被
那个更早的响应顺手清掉,然后永远送不到服务端——一个丢失的阅读位置,是这个产品
最不能有的失败。

阅读位置格式 r1:<偏移>:<章节 id>。三点都是踩过之后定的:版本前缀(服务端把 locator
当不透明字符串长期保存,客户端将来改格式必须能认出「这不是我认识的格式」)、
章节 id 放最后(它是文件路径,会含冒号,只从前两个冒号切才不会丢数据)、
偏移是章节内的比例而非像素或列号(列号会随字号变化,于是读者一调字号位置就跳)。

TXT、漫画、PDF 的支持也一并接上,PDF 用浏览器自带阅读器保底可读。

## 服务端

- GET /books/:id/file?path=… :按页取图。path 必须是这本书已登记的 rel_path,
  服务端按表精确匹配,不用 query 拼路径,所以穿越只会匹配到空。
- CORS 白名单(CORS_ORIGINS,默认回显任意来源)+ H5 静态托管(WEB_DIR)。
  托管在同一容器里,这才保住「一条命令跑起来」。
- Android 用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供
  打包好的客户端,而不是 file://。后者发出的 fetch 是不透明的跨源请求,所有访问
  服务端的调用都会被拦,而通常的绕法是把 web security 关掉——那等于把 WebView
  变成一扇敞开的门。用资产加载器拿到的是正常安全源,请求按普通 CORS 走,
  isSecureContext 为真,IndexedDB 等行为与浏览器一致。这一个选择是「一份 bundle
  两个宿主」能成立的前提。

## 过程中发现并修掉的问题

- **ETag 里塞了中文路径**:HTTP 头值必须是 ISO-8859-1,漫画页码路径是中文时会抛
  ERR_INVALID_CHAR,把一个正常的取图请求变成 500。改成对路径取散列。
- **漫画文件夹的主键用了页集合散列**:注释写的是「加一页不产生新书」,实现却是散列
  页集合,加一页就换 id、进度全丢。改成用文件夹路径作为锚点,和书用 dc:identifier
  作锚点是同一个道理。测试固定住了。
- **locator 解析与写出不对称**:写出是两段、解析却按三段切,导致位置根本存不回来。
  章节 id 含冒号时更糟。
- **离线队列的清理条件**:原本按「清空全部」处理,会把请求在途时产生的新位置一并
  抹掉。改成只清那一批、且只清时间戳吻合的条目。
- **测试自身的错误**:并发刷新去重的用例里,我一开始用 resolveRefresh?.() 调一个
  类型已收窄成 never 的变量,断言等于什么都没测;「outbox 为空时不推」的用例里把
  登录的 POST 也数了进去,断言恒不成立。两处都是测试写错了。
- **凭据用错了**:一个权限用例里我用 createAsAdmin 建账号却拿另一组凭据登录,
  401 被当成「越权被拒」而通过。这也是测试写错了。

## 验证方式

```
cd server && npm run typecheck && npm test && npm run build   # 37/37 通过
cd web    && npm run typecheck && npm test && npm run build   # 147/147 通过
sh android/scripts/check-shell.sh                             # 桥接契约双向校验通过
```

另用真实 HTTP 请求端到端验证过:注册 → 扫描 → 书架(四种格式全部可见)→ 漫画按页
取图(含中文路径 ETag)→ 列表页 → 进度同步 → 继续阅读;H5 静态托管、SPA 回落、
CORS 回显、路径穿越拒绝均按预期。

Android 壳本轮**没有真机编译验证**:环境里没有 JDK 和 Android SDK。Kotlin 侧做了
静态校验(包名与目录一致、项目内 import 可解析、桥接方法与 web 侧声明双向对齐),
并接进了 CI。第一次真机构建可能需要微调 Gradle 版本或 AGP 版本。

## 说明

- docker-compose.yml 与 Dockerfile 已改为从仓库根构建并打包 H5 产物;
  build 上下文变了,如果之前用了 `build: ./server` 需要改成根目录。
- iOS 未做设计(按设计文档 §7,等有赞助再说)。
- 移动端批注输入仍未做(先保证读,后补写)。

Ref: #1
2026-09-16 14:49:11 +08:00

13 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 干预策略,保留作者样式、只做最小覆盖
  • 参考 readium-css 的变量组织方式,但不引入它的默认接管行为

只有一份渲染层

Android 不重写渲染,它加载的是同一份 web/ 产物。这不是为了省事, 而是因为排版策略一旦有第二份实现就一定会分叉,而排版正是差异点所在。

两边共用的抽象在 web/src/core/platform.ts:传输、键值存储、二进制存储、连通性。 Android 只覆盖它真正能做得更好的部分——连通性和设备名——其余全部复用。

Android 用 https 资产源,不用 file://

把打包好的 Web 应用塞进 assets,最直觉的加载方式是 file:///android_asset/...。 这条路对这个产品是走不通的:从 file:// 源发出的 fetch() 是不透明的跨源请求, 所有访问服务端的调用都会被拦,而通常的绕法是把 web security 关掉—— 那就等于把 WebView 变成一扇敞开的门。

用 WebViewAssetLoader 从 https://appassets.androidplatform.net/ 提供同样的文件, 拿到的是一个正常的安全源:请求按普通 CORS 走,正好和服务端的 CORS 策略对上; isSecureContext 为真,IndexedDB 等 API 与浏览器里行为一致。 这一个选择就是「一份 bundle 两个宿主」能成立的前提。

精排 CSS:干预策略

按重要程度排序的三条原则,写在 web/src/styles/reader.css 里:

  1. 不设任何属性,除非不设会坏。
  2. 每个可调项都是默认 inherit 的可继承自定义属性——「关」的意思是「不动它」, 不是「用我们的默认值」。这一点很关键:给行距写死一个具体值,会把一本按章节 变行距的书压平。
  3. 任何改变作者意图的行为都是用户主动开启,且持久化,绝不是默认。

实际 override 只有七处,每处都在 CSS 注释里写了为什么:图片/表格/pre 在窄屏会撑破 布局、ruby 振假名在 16px 下按默认 50% 太小、长中文标题不换行会溢出。除此之外不动。

对比一下被删掉的做法:reset 样式表、统一的「阅读主题」、给 p 加边距。 它们都能让排版「看起来一致」,而一致性恰恰是这里要避免的东西。

书的样式隔离在 shadow root 里

书里一条 p { color: red } 不能改到书架,App 的 .topbar 也不能被书撞上。 iframe 同样能隔离,但每次换章要重建文档:滚动位置丢失、Android 上内存翻几倍。 shadow root 拿到完整样式隔离且没有文档边界,App 还能进去做分页和取选中。 关键是不往 shadow root 里注入任何 reset。

书不能联网

加载时把章节里所有本地引用改写成内部 reader-res: 协议,渲染层从内存资源表解出; 绝对 URL 原样保留,然后被 CSP 挡掉。这样一本来路不明的书无法把阅读行为报到远端。 资源按需解出而不是预展开——不然打开一本带大图的 EPUB 会先把整个包塞进内存。

阅读位置格式

r1:<偏移>:<章节 id>。三点都是踩过之后定的:

  • 版本前缀:服务端把 locator 当不透明字符串长期保存,客户端将来改格式时必须 能认出「这不是我认识的格式」。r1 给了这个能力。
  • 章节 id 放最后:章节 id 是文件路径,会含冒号。只从前两个冒号切、剩下全保留, 是唯一不会丢数据的解析方式。
  • 偏移是章节内的比例,不是像素、不是 CSS 列号。列号会随字号变化, 于是读者一调字号,位置就跳到另一个句子上——阅读 App 能犯的最让人恼火的错误之一。

待发送队列,而不是「每次都全发」

离线队列记的是脏集合:哪些记录还没送到服务端,而不是「当前全部状态」。

两者在一种情况下结果不同,而这种情况一定会发生:记录在请求发出之后、响应到达之前 被修改了(读者在同步进行时翻了一页)。脏集合下,这次更新仍然是脏的,要等它自己 的确认才清除;「全发」模式下,它会被那个更早的响应顺手清掉,然后永远送不到服务端—— 一个丢失的阅读位置,是这个产品最不能有的失败。

离线队列的抗崩

队列在本地镜像里,不在内存里。所以读到一半直接杀进程,位置不会丢—— 下一次启动接着送。笔记删除同样写墓碑后再入队,避免离线设备让已删笔记复活。

TXT 的编码与章节

本地书库里 TXT 占比很大,而它既没有结构也没有编码声明。

编码:先严格校验 UTF-8(自己实现,不用 TextDecoder 的宽松模式——宽松模式永远 「成功」,于是你永远发现不了自己解错了)。校验失败再依次试 GB18030、Big5。 结果里带 guessed 标记,界面上告诉用户选了哪个并允许手动覆盖。

章节:TXT 没有目录,只能从标题推断。策略是宁可少认,不要错认—— 一个 800 字的段落以「第三章」开头时不能当标题,而正文里提到「他在第二章读过」 更不能当标题。两条都有测试固定住。推断不出章节时按长度分段: 一本五千章的网文如果变成一整节,分页会直接卡死。

漫画的页序

按自然序排(1, 2, 10 而不是 1, 10, 2)。排错不是观感问题,是故事被静默打乱。 文件名无信息量时用页序当标题,因为 a1b2c3.png 对读者没有意义。

一页一个 section

漫画和 PDF 是 layout: 'fixed',每页一个 section,由分页器整屏推进。 固定布局的宿主不做滚动容器——在横向分页器里嵌一个滚动区会产生 Android 上经典的「页面自己往边上飘」bug。