docs: book-comic-library spec + backend implementation plan
This commit is contained in:
@@ -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。
|
||||
Reference in New Issue
Block a user