# 个人书库 / 漫画库 — 设计文档 日期: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 `;标 ★ 的仅 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。