Files
book-comic-library/docs/superpowers/specs/2026-09-04-book-comic-library-design.md
T

8.4 KiB
Raw Blame History

个人书库 / 漫画库 — 设计文档

日期:2026-09-04 状态:待评审

1. 概述

Web 端个人书库/漫画库:多用户、在线阅读、阅读进度、缓存,Docker Compose 部署,nginx 将后端 API 收口到 /api/ 前缀。前后端分离,后端无状态、可水平扩容。

非目标(明确不做):

  • 元数据刮削/在线编辑/改名(文件名即元数据,改名 = 磁盘上移动文件)
  • 格式转换(PDF/EPUB 原样流给前端渲染,不转图片流)
  • CBR/rar、音频书、OPDS、开放注册、社交功能

2. 技术栈

层 选型
后端 Go + Gin,golang-jwt/v5,bcrypt,pgx,go-redis/v9
前端 React + Vite + TypeScript,TanStack Query,React Router,Tailwind(默认深色)
阅读器 CBZ:自制虚拟滚动图片页;PDF:PDF.js;EPUB:epub.js;TXT/MD:marked + DOMPurify
存储 Postgres(真源)、Redis(可丢热缓存)、共享磁盘 volume(文件 + 衍生缓存)
部署 Docker Compose + nginx

3. 架构

浏览器 ── / (SPA 静态) ──> nginx(web 镜像)
       └─ /api/* ────────> nginx → api:8080 ×N (Go, --scale api=N)
                              │
             ┌────────────────┼─────────────────┐
          postgres          redis           /data volume
     (用户/书/进度真源)  (热缓存/限流/扫描锁,  (原始书 + 衍生缓存,
                         可整库清空)         所有副本共享)

核心不变式:Postgres 是元数据唯一真源,/data 是文件唯一真源,Redis 与浏览器 SW 缓存只是可丢弃的加速层。杀掉任意 api 副本、清空 Redis、清空 /data/cache 均不丢用户数据。

3.1 入库链路(扫描 + 上传合一)

  • libraries.root_path 指向 /data/books/<库>/,scanner(进程内后台 goroutine 池)周期扫描(默认 60s,env 可调)+ admin 可手动触发 POST /scan。
  • 新文件按 path+mtime+size 识别;同 path 但 size/mtime 变了 → 更新行并失效缓存;文件消失 → 删行(进度保留)。
  • 上传:multipart 写入库目录的临时文件,rename 到最终路径,scanner 自动收编 → 上传与磁盘落文件走同一条链路,无第二套入库代码。

3.2 无状态

  • JWT(HS256,secret 来自 env)纯本地校验,不存 session。
  • 多副本并发扫描/解压用 Redis SET NX 锁去重。
  • 衍生文件与上传都在共享 volume,任意副本可服务任意请求。

4. 数据模型

users       (id pk, username unique, password_hash, role enum('admin','member'), created_at)
libraries   (id pk, name, root_path, created_at)
books       (id pk, library_id fk, path unique-per-library, title, format enum('cbz','pdf','epub','txt','md'),
             file_size, mod_ts, page_count nullable, state enum('ready','error'), error_msg, added_at)
reading_progress (user_id fk→users, library_id, book_path, locator jsonb, percent double, updated_at,
                  PRIMARY KEY(user_id, library_id, book_path))
  • 无 series 表:path 去掉文件名即分组键,前端按目录聚合出"系列"视图。
  • locator 按格式自由:CBZ {page} / EPUB {cfi} / PDF {page} / TXT {scrollFraction};后端不解释,仅前端读写。percent 冗余给列表页。
  • 进度按 (user, library_id, book_path) 键控而非 book_id:path 是 scanner 使用的稳定身份,删书/重扫导致 books 行重建后进度依然能续上。PUT /api/books/{id}/progress 由后端把 id 解析成 (library_id, path)。

5. API

全部位于 /api/*;除 /api/healthz 外需 Authorization: Bearer <jwt>;标 ★ 的仅 admin(其余 member 可访问)。

POST   /api/auth/login                          {username,password} → {token}
GET    /api/auth/me

GET    /api/users                               ★
POST   /api/users                               ★ {username,password,role}
DELETE /api/users/{id}                          ★

GET    /api/libraries
POST   /api/libraries                           ★ {name,root_path}
POST   /api/libraries/{id}/scan                 ★ → 202 异步
POST   /api/libraries/{id}/upload  multipart    ★ → 202

GET    /api/books?library=&q=&prefix=           列表:内嵌封面URL+请求者进度
GET    /api/books/{id}
DELETE /api/books/{id}                          ★ 删行+删文件+清衍生缓存(进度保留)
GET    /api/books/{id}/cover                    image
GET    /api/books/{id}/file                     原始 pdf/epub/txt/md,支持 Range
GET    /api/books/{id}/pages                    {count}                    (cbz)
GET    /api/books/{id}/pages/{n}                image                      (cbz)

PUT    /api/books/{id}/progress                 {locator,percent} upsert
GET    /api/progress                            本人全部进度(继续阅读)

无 PATCH/编辑元数据端点(非目标)。

6. 缓存

6.1 磁盘衍生缓存(/data/cache/)

/data/cache/covers/{bookId}-{hash}.jpg      hash = hex(mtime_unix + "_" + size)
/data/cache/pages/{bookId}-{hash}/{n}.jpg   CBZ 解压页

URL 带 hash → 内容永久不变 → Cache-Control: public, max-age=31536000, immutable + 强 ETag。失效即换 URL,没有任何服务端缓存失效逻辑;旧 hash 目录由 scanner 顺手清理。

CBZ 取页:命中缓存直接吐;miss 则按 zip 页索引随机读该页、落盘、返回。

6.2 Redis(全可丢,无 volume)

pagesidx:{bookId}:{hash}   CBZ zip 中央目录解析出的页清单,TTL 7d
loginrl:{ip}               登录限流 INCR+EXPIRE,60s 窗口 5 次
scan:{libraryId}           扫描互斥锁 SET NX + TTL,防副本重复扫

不存在"DB 和 Redis 双写"的数据,Redis miss 一律回源。

6.3 前端离线

Service Worker(vite-plugin-pwa / Workbox):/api 普通请求 network-first;带 hash 的不可变资源(封面/页/文件)cache-first。读过的漫画页离线可翻。

7. 安全

  • 密码 bcrypt;登录按 IP Redis 限流。
  • 路径安全(硬要求,带测试):所有文件访问 filepath.Clean 后强制前缀校验在库 root 内;CBZ 解页拒绝含 ../绝对路径的 zip 条目(zip-slip);上传扩展名白名单 + 大小限制 + temp/rename 原子落盘。
  • JWT 存 localStorage(XSS 面由 CSP + React 默认转义 + DOMPurify 收;个人应用可接受)。
  • 写操作仅 admin;member 只读 + 写本人进度。

8. 前端

/login
/              书架:库 → 目录分组卡片,封面+进度条,搜索,损坏态
/book/:id      阅读器:按 format 分发
/admin/users   用户管理
/admin/libraries  建库 / 触发扫描 / 上传
  • 状态:TanStack Query(进度 upsert 后仅 invalidate 单本)+ Router;无全局 store。
  • 四种 reader 统一 onPositionChange(locator, percent) 接口;进度 PUT 节流 5s,sendBeacon 兜底关页。
  • Tailwind,深色默认,无主题切换系统。

9. 错误处理

  • 统一错误体 {"error":{"code","message"}} + 正确 HTTP 状态码;Gin recovery。
  • scanner 单本失败 → state=error + error_msg,不中断整库扫描。
  • Redis 不可用 → 全部当 miss 降级(直读 DB/zip),功能不瘫;PG 不可用 → 503。
  • 前端:401 拦截清 token 跳登录;全局 toast + ErrorBoundary;reader 图片失败显示重试占位。

10. 测试

后端(标准库 testing + httptest,fixtures 含正常/坏/含 ../ 条目的 zip):

  • 路径消毒、zip-slip、前缀越界用例表
  • scanner 三态 diff(temp dir 构造 new/changed/deleted)
  • login/JWT/bcrypt、progress upsert、CBZ 取页与缓存落盘
  • 集成:docker compose --profile test 起 PG 打全 API

前端:tsc + vite build 为 CI 门槛;vitest 覆盖进度节流/beacon 逻辑。

11. 部署

# docker-compose.yml(要点)
services:
  web:    # 多阶段: 前端构建产物 + nginx 配置打进一个镜像; :8080 → :80
  api:    # 多阶段 Go → alpine; --scale api=N; env: JWT_SECRET, DATABASE_URL,
          # REDIS_URL, ADMIN_USER, ADMIN_PASSWORD; volumes: data:/data
  postgres: # volume pgdata, healthcheck pg_isready
  redis:    # 无 volume:内容全部可再生
挂载:./library bind → /data/books(宿主放书);命名卷 data → /data/cache(衍生缓存,跨副本共享)
  • nginx:location /api/ { proxy_pass http://api:8080; } 保留 /api 前缀(后端路由一致,无 rewrite);SPA try_files $uri /index.html;resolver 127.0.0.11 valid=10s + 变量式 proxy_pass,保证 scale 后 DNS 轮询到新副本。
  • 首次启动:users 表为空时用 ADMIN_USER/ADMIN_PASSWORD 自动创建首个 admin。
  • .env 管密钥,不进 git。