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

180 lines
8.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 个人书库 / 漫画库 — 设计文档
日期: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. 数据模型
```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 由服务端生成(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. 部署
```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。