8.5 KiB
个人书库 / 漫画库 — 设计文档
日期:2026-09-04 状态:已实现(Plan 1 backend+deploy、Plan 2 frontend 均已合并至 master)
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 由服务端生成(BooksDir/清洗后的库名)
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);SPAtry_files $uri /index.html;resolver 127.0.0.11 valid=10s+ 变量式proxy_pass,保证 scale 后 DNS 轮询到新副本。 - 首次启动:users 表为空时用
ADMIN_USER/ADMIN_PASSWORD自动创建首个 admin。 .env管密钥,不进 git。