docs: book-comic-library spec + backend implementation plan

This commit is contained in:
2026-09-04 23:36:04 +08:00
commit 2458c9ca4d
3 changed files with 4581 additions and 0 deletions
@@ -0,0 +1,179 @@
# 个人书库 / 漫画库 — 设计文档
日期: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. 数据模型
```sql
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. 部署
```yaml
# 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。