commit 2458c9ca4d980e181c7f0d1f0bcab661d0ec9850 Author: Fendy Date: Fri Sep 4 23:36:04 2026 +0800 docs: book-comic-library spec + backend implementation plan diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a2c612 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +library/ +.superpowers/ +backend/server diff --git a/docs/superpowers/plans/2026-09-04-backend.md b/docs/superpowers/plans/2026-09-04-backend.md new file mode 100644 index 0000000..4858719 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-backend.md @@ -0,0 +1,4398 @@ +# Backend & Deploy Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现个人书库/漫画库的 Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、三层缓存的磁盘+Redis 两层)与 Docker Compose 部署(nginx 收口 `/api/`)。 + +**Architecture:** 单 Go 服务多副本无状态;Postgres 为唯一元数据真源,`/data` 共享卷存原始书与按内容 hash 寻址的不可变衍生缓存,Redis 只放可丢的热缓存/限流/扫描锁。设计不变式见 spec §3。 + +**Tech Stack:** Go 1.22+, Gin, jackc/pgx/v5, golang-jwt/jwt/v5, x/crypto/bcrypt, go-redis/v9, archive/zip, encoding/xml。 + +**Spec:** `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`(本仓库根 = `book-comic-library/`,所有相对路径以此为根) + +**范围:** 本 plan 只做 backend + deploy + 一个占位 web;四个 reader 与书架 UI 在 Plan 2(`2026-09-04-frontend.md`,backend 完成后编写)。 + +## Global Constraints + +- 后端全部挂 `/api/*`;除 `/api/healthz` 与 `/api/auth/login` 外都要 `Authorization: Bearer `。 +- 写操作(POST/PUT/DELETE)仅 `role=admin`;member 可 GET 一切 + PUT 自己的 progress。 +- 格式白名单:`cbz pdf epub txt md`(无 CBR、无格式转换)。 +- 衍生缓存文件永不原地覆盖:内容变 → hash 变 → 新文件名;URL 带 `?v={hash}` 且响应 `Cache-Control: public,max-age=31536000,immutable`。 +- Redis 不可用一律降级为 miss/放行,不得报错;Redis 容器无 volume。 +- 任何文件访问前 `filepath.Clean` + 校验仍在库 root 内;zip 条目名含 `..`、绝对路径、`\` 一律拒绝。 +- 密码 bcrypt cost 12;JWT HS256,72h 过期,secret 来自 env。 +- 测试只用标准库 `testing`(httptest);依赖 PG 的测试当 `DATABASE_URL` 未设置时 `t.Skip`。 +- Go module 名: `booklib`,代码在 `backend/`。 + +## File Structure + +``` +backend/ + go.mod + cmd/server/main.go # 装配:config→pg→redis→store→scanner→router,优雅退出 + internal/ + config/config.go # env → Config + db/db.go # pgxpool 连接 + //go:embed schema.sql 幂等迁移 + db/schema.sql + auth/auth.go # bcrypt, JWT Sign/Parse, Claims + redispkg/redis.go # 可为 nil 的 Redis 包装: Get/Set/IncrWindow/Lock,全降级安全 + store/store.go # Store + 全部 SQL(users/libraries/books/progress)+ 类型 + bookfile/bookfile.go # FormatFromExt/FileHash/NaturalLess + bookfile/zip.go # PageIndex/ReadPage(防zip-slip) + bookfile/cover.go # CBZCover/EPUBCover(container.xml+OPF) + scanner/scanner.go # 周期+单次扫描,diff,封面/page_count,缓存清扫 + api/api.go # api struct,错误体,middleware(auth/admin),userReq + api/router.go # NewRouter:全部路由注册 + api/auth.go # login/me/users + api/libraries.go # libraries CRUD + scan/upload + api/books.go # list/detail/delete + api/content.go # cover/file/pages/page + api/progress.go # progress PUT/GET + *_test.go 与上面各文件同目录同名 +deploy/ + Dockerfile.api # 多阶段 → alpine + Dockerfile.web # 占位: nginx + 一个 index.html(Plan 2 替换为 SPA) + nginx.conf # /api/ 反代(变量+resolver 保 scale), try_files +docker-compose.yml # web, api(xN), postgres, redis, testprofile +.env.example +``` + +--- + +### Task 1: 脚手架 — module、config、Gin healthz + +**Files:** +- Create: `backend/go.mod`(via go mod init) +- Create: `backend/internal/config/config.go` +- Create: `backend/api` 相关:先只建 `backend/internal/api/router.go`(仅 healthz) +- Test: `backend/internal/api/router_test.go` + +**Interfaces:** +- Consumes: 无 +- Produces: `config.Config{Addr, DatabaseURL, RedisURL string, JWTSecret []byte, AdminUser, AdminPassword, BooksDir, CacheDir string, ScanInterval time.Duration, UploadMaxMB int64}`;`config.Load() (*Config, error)`;`api.NewRouter(cfg *config.Config) *gin.Engine` + +- [ ] **Step 1: 初始化 module** + +```bash +cd backend && go mod init booklib +go get github.com/gin-gonic/gin@latest github.com/jackc/pgx/v5@latest github.com/redis/go-redis/v9@latest github.com/golang-jwt/jwt/v5@latest golang.org/x/crypto@latest +``` + +- [ ] **Step 2: 写失败测试 `internal/api/router_test.go`** + +```go +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "booklib/internal/config" +) + +func testCfg() *config.Config { + return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200} +} + +func TestHealthz(t *testing.T) { + r := NewRouter(testCfg()) + req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("healthz = %d, want 200", w.Code) + } +} +``` + +- [ ] **Step 3: 跑测试确认失败** + +Run: `cd backend && go test ./internal/api/ -run TestHealthz -v` +Expected: 编译失败 "undefined: NewRouter" + +- [ ] **Step 4: 实现 config + router** + +`internal/config/config.go`: + +```go +package config + +import ( + "fmt" + "os" + "strconv" + "time" +) + +type Config struct { + Addr string + DatabaseURL string + RedisURL string + JWTSecret []byte + AdminUser string + AdminPassword string + BooksDir string + CacheDir string + ScanInterval time.Duration + UploadMaxMB int64 +} + +func Load() (*Config, error) { + env := func(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def + } + scanSec, err := strconv.Atoi(env("SCAN_INTERVAL_SEC", "60")) + if err != nil { + return nil, fmt.Errorf("SCAN_INTERVAL_SEC: %w", err) + } + uploadMB, err := strconv.ParseInt(env("UPLOAD_MAX_MB", "200"), 10, 64) + if err != nil { + return nil, fmt.Errorf("UPLOAD_MAX_MB: %w", err) + } + secret := os.Getenv("JWT_SECRET") + if secret == "" { + return nil, fmt.Errorf("JWT_SECRET required") + } + return &Config{ + Addr: env("ADDR", ":8080"), + DatabaseURL: env("DATABASE_URL", ""), + RedisURL: env("REDIS_URL", ""), + JWTSecret: []byte(secret), + AdminUser: env("ADMIN_USER", ""), + AdminPassword: env("ADMIN_PASSWORD", ""), + BooksDir: env("BOOKS_DIR", "/data/books"), + CacheDir: env("CACHE_DIR", "/data/cache"), + ScanInterval: time.Duration(scanSec) * time.Second, + UploadMaxMB: uploadMB, + }, nil +} +``` + +`internal/api/router.go`(后续任务在此文件继续加路由;签名会在 Task 4/9 演进为 `NewRouter(cfg, st, rdb, sc)`): + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "booklib/internal/config" +) + +type api struct { + cfg *config.Config + // st, rdb, sc 字段在 Task 4/9 加入 +} + +func NewRouter(cfg *config.Config) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + a := &api{cfg: cfg} + r := gin.New() + r.Use(gin.Recovery()) + g := r.Group("/api") + g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + return r +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd backend && go test ./internal/api/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git init -b main . && git add backend && git commit -m "feat(backend): module scaffold, config, gin router with healthz" +``` + +(若仓库已有 git 则跳过 `git init`。) + +--- + +### Task 2: Postgres — 连接、幂等迁移、store 全量查询 + +**Files:** +- Create: `backend/internal/db/db.go`, `backend/internal/db/schema.sql` +- Create: `backend/internal/store/store.go` +- Test: `backend/internal/store/store_test.go` +- Create: `deploy/docker-compose.dev.yml`(本地测试用 pg+redis) + +**Interfaces:** +- Consumes: 无 +- Produces(store 包,后续任务全部依赖): + +```go +type User struct{ ID int64; Username, PasswordHash, Role string; CreatedAt time.Time } +type Library struct{ ID int64; Name, RootPath string; CreatedAt time.Time } +type Book struct{ ID, LibraryID int64; Path, Title, Format string; FileSize, ModTS int64; PageCount int; State, ErrMsg string; AddedAt time.Time } // PageCount==0 表示未知(pdf/epub/txt) +type BookMeta struct{ ID int64; Size, ModTS int64; Format string } +type BookView struct{ Book; LibraryName string; Percent float64 } // 列表联查结果 +type Progress struct{ LibraryID int64; LibraryName, BookPath, Title string; Locator []byte; Percent float64; UpdatedAt time.Time } // Title 为空 = 书已删,进度保留 + +func New(p *pgxpool.Pool) *Store +// users +(*Store) CountUsers(ctx) (int, error) +(*Store) CreateUser(ctx, username, passwordHash, role string) (int64, error) +(*Store) GetUserByName(ctx, username string) (User, error) // pgx.ErrNoRows 透传 +(*Store) GetUserByID(ctx, id int64) (User, error) +(*Store) ListUsers(ctx) ([]User, error) +(*Store) DeleteUser(ctx, id int64) error +// libraries +(*Store) CreateLibrary(ctx, name, rootPath string) (int64, error) +(*Store) ListLibraries(ctx) ([]Library, error) +(*Store) GetLibrary(ctx, id int64) (Library, error) +// books +(*Store) InsertBook(ctx, libraryID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) +(*Store) GetBook(ctx, id int64) (Book, error) +(*Store) ListBookMeta(ctx, libraryID int64) (map[string]BookMeta, error) // key=Path +(*Store) UpdateBookFile(ctx, id, size, modTS int64, pageCount int) error +(*Store) DeleteBookByPath(ctx, libraryID int64, path string) error +(*Store) DeleteBook(ctx, id int64) error +(*Store) SetBookState(ctx, id int64, state, errMsg string) error +(*Store) ListBookIDs(ctx) ([]int64, error) +(*Store) ListBooks(ctx, libraryID int64, q, prefix string, userID int64) ([]BookView, error) // libraryID=0 全部 +// progress (key = user_id + library_id + book_path, 见 spec §4) +(*Store) UpsertProgress(ctx, userID, libraryID int64, bookPath string, locator []byte, percent float64) error +(*Store) ListProgress(ctx, userID int64) ([]Progress, error) +(*Store) GetProgress(ctx, userID, libraryID int64, bookPath string) (Progress, error) + +func db.Connect(ctx, url string) (*pgxpool.Pool, error) +func db.Migrate(ctx, p *pgxpool.Pool) error // 执行内嵌 schema.sql,幂等 +``` + +- [ ] **Step 1: 起测试用 PG/Redis** + +`deploy/docker-compose.dev.yml`: + +```yaml +services: + postgres: + image: postgres:16-alpine + environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib } + ports: ["5433:5432"] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 } + redis: + image: redis:7-alpine + ports: ["6380:6379"] +``` + +Run: `docker compose -f deploy/docker-compose.dev.yml up -d --wait` +Expected: 两个 healthy。后续所有集成测试用 `DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable`。 + +- [ ] **Step 2: 写 schema.sql(内嵌,幂等)** + +`internal/db/schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin','member')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS libraries ( + id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, root_path TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now()); +CREATE TABLE IF NOT EXISTS books ( + id BIGSERIAL PRIMARY KEY, library_id BIGINT NOT NULL REFERENCES libraries(id), + path TEXT NOT NULL, title TEXT NOT NULL, + format TEXT NOT NULL CHECK (format IN ('cbz','pdf','epub','txt','md')), + file_size BIGINT NOT NULL, mod_ts BIGINT NOT NULL, page_count INT NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'ready' CHECK (state IN ('ready','error')), + error_msg TEXT NOT NULL DEFAULT '', added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (library_id, path)); +CREATE TABLE IF NOT EXISTS reading_progress ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + library_id BIGINT NOT NULL, book_path TEXT NOT NULL, + locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, library_id, book_path)); +``` + +- [ ] **Step 3: 写失败测试 `store_test.go`(全部 SQL 行为在此锁死)** + +```go +package store + +import ( + "context" + "os" + "testing" + + "booklib/internal/db" +) + +func setup(t *testing.T) *Store { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, err := db.Connect(ctx, url) + if err != nil { + t.Fatal(err) + } + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + s := New(p) + for _, q := range []string{ + "DELETE FROM reading_progress", "DELETE FROM books", "DELETE FROM libraries", "DELETE FROM users"} { + if _, err := p.Exec(ctx, q); err != nil { + t.Fatal(err) + } + } + return s +} + +func TestBooksDiffLifecycle(t *testing.T) { + s := setup(t) + ctx := context.Background() + libID, err := s.CreateLibrary(ctx, "comics", "/data/books/comics") + if err != nil { + t.Fatal(err) + } + bid, err := s.InsertBook(ctx, libID, "series-a/01.cbz", "01", "cbz", 100, 1000, 24) + if err != nil { + t.Fatal(err) + } + meta, err := s.ListBookMeta(ctx, libID) + if err != nil { + t.Fatal(err) + } + if m := meta["series-a/01.cbz"]; m.ID != bid || m.Size != 100 { + t.Fatalf("bad meta %+v", m) + } + if err := s.UpdateBookFile(ctx, bid, 200, 2000, 25); err != nil { + t.Fatal(err) + } + b, _ := s.GetBook(ctx, bid) + if b.FileSize != 200 || b.PageCount != 25 { + t.Fatalf("update failed: %+v", b) + } + if err := s.SetBookState(ctx, bid, "error", "boom"); err != nil { + t.Fatal(err) + } + b, _ = s.GetBook(ctx, bid) + if b.State != "error" || b.ErrMsg != "boom" { + t.Fatal("state not set") + } + // changed + deleted 的 diff 输入验证 + meta, _ = s.ListBookMeta(ctx, libID) + if _, ok := meta["nope.cbz"]; ok { + t.Fatal("unexpected row") + } + if err := s.DeleteBookByPath(ctx, libID, "series-a/01.cbz"); err != nil { + t.Fatal(err) + } + if _, err := s.GetBook(ctx, bid); err == nil { + t.Fatal("book still exists") + } +} + +func TestProgressUpsertAndJoin(t *testing.T) { + s := setup(t) + ctx := context.Background() + uid, _ := s.CreateUser(ctx, "u1", "h", "member") + libID, _ := s.CreateLibrary(ctx, "l", "/data/books/l") + bid, err := s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3) + if err != nil { + t.Fatal(err) + } + if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":2}`), 0.5); err != nil { + t.Fatal(err) + } + if err := s.UpsertProgress(ctx, uid, libID, "x/a.cbz", []byte(`{"page":3}`), 0.9); err != nil { + t.Fatal(err) // upsert 不报错 + } + p, err := s.GetProgress(ctx, uid, libID, "x/a.cbz") + if err != nil || p.Percent != 0.9 || string(p.Locator) != `{"page":3}` { + t.Fatalf("bad progress %+v %v", p, err) + } + list, _ := s.ListProgress(ctx, uid) + if len(list) != 1 { + t.Fatalf("want 1 got %d", len(list)) + } + // 删书行,进度保留(spec §4) + if err := s.DeleteBook(ctx, bid); err != nil { + t.Fatal(err) + } + if _, err := s.GetProgress(ctx, uid, libID, "x/a.cbz"); err != nil { + t.Fatal("progress lost with book") + } + // 列表联查带本人 percent + _, _ = s.InsertBook(ctx, libID, "x/a.cbz", "a", "cbz", 1, 1, 3) + views, _ := s.ListBooks(ctx, 0, "", "", uid) + if len(views) != 1 || views[0].Percent != 0.9 { + t.Fatalf("views %+v", views) + } +} +``` + +(BookMeta 只带 diff 需要的 ID/Size/ModTS/Format;page_count 走 GetBook,测试里已有覆盖。) + +- [ ] **Step 4: 跑测试确认失败** + +Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v` +Expected: 编译失败 "booklib/internal/db: no such package" + +- [ ] **Step 5: 实现 db.go 与 store.go** + +`internal/db/db.go`: + +```go +package db + +import ( + "context" + _ "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed schema.sql +var schema string + +func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) { + cfg, err := pgxpool.ParseConfig(url) + if err != nil { + return nil, err + } + cfg.MaxConns = 10 + return pgxpool.NewWithConfig(ctx, cfg) +} + +func Migrate(ctx context.Context, p *pgxpool.Pool) error { + if _, err := p.Exec(ctx, schema); err != nil { + return fmt.Errorf("migrate: %w", err) + } + return nil +} +``` + +`internal/store/store.go` — 类型见 Interfaces;实现(每个方法都是直白 SQL,完整给出): + +```go +package store + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct{ P *pgxpool.Pool } + +func New(p *pgxpool.Pool) *Store { return &Store{P: p} } + +// ---------- types ---------- + +type User struct { + ID int64 + Username string + PasswordHash string + Role string + CreatedAt time.Time +} +type Library struct { + ID int64 + Name string + RootPath string + CreatedAt time.Time +} +type Book struct { + ID, LibraryID int64 + Path string + Title string + Format string + FileSize int64 + ModTS int64 + PageCount int + State string + ErrMsg string + AddedAt time.Time +} +type BookMeta struct { + ID int64 + Size int64 + ModTS int64 + Format string +} +type BookView struct { + Book + LibraryName string + Percent float64 +} +type Progress struct { + LibraryID int64 + LibraryName string + BookPath string + Title string // 书已删时为空 + Locator []byte + Percent float64 + UpdatedAt time.Time +} + +// ---------- users ---------- + +const userCols = "id, username, password_hash, role, created_at" + +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + err := s.P.QueryRow(ctx, "SELECT count(*) FROM users").Scan(&n) + return n, err +} + +func (s *Store) CreateUser(ctx context.Context, username, hash, role string) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + "INSERT INTO users (username, password_hash, role) VALUES ($1,$2,$3) RETURNING id", + username, hash, role).Scan(&id) + return id, err +} + +func (s *Store) GetUserByName(ctx context.Context, username string) (User, error) { + return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE username=$1", username)) +} + +func (s *Store) GetUserByID(ctx context.Context, id int64) (User, error) { + return scanUser(s.P.QueryRow(ctx, "SELECT "+userCols+" FROM users WHERE id=$1", id)) +} + +func (s *Store) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.P.Query(ctx, "SELECT "+userCols+" FROM users ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + _, err := s.P.Exec(ctx, "DELETE FROM users WHERE id=$1", id) + return err +} + +func scanUser(row pgx.Row) (User, error) { + var u User + err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.CreatedAt) + return u, err +} + +// CountAdmins 供 Task 5 的"最后一个 admin 不可删"保护 +func (s *Store) CountAdmins(ctx context.Context) (int, error) { + var n int + err := s.P.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n) + return n, err +} + +```go +// ---------- libraries ---------- + +func (s *Store) CreateLibrary(ctx context.Context, name, root string) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + "INSERT INTO libraries (name, root_path) VALUES ($1,$2) RETURNING id", name, root).Scan(&id) + return id, err +} + +func (s *Store) ListLibraries(ctx context.Context) ([]Library, error) { + rows, err := s.P.Query(ctx, "SELECT id, name, root_path, created_at FROM libraries ORDER BY id") + if err != nil { + return nil, err + } + defer rows.Close() + var out []Library + for rows.Next() { + var l Library + if err := rows.Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt); err != nil { + return nil, err + } + out = append(out, l) + } + return out, rows.Err() +} + +func (s *Store) GetLibrary(ctx context.Context, id int64) (Library, error) { + var l Library + err := s.P.QueryRow(ctx, + "SELECT id, name, root_path, created_at FROM libraries WHERE id=$1", id). + Scan(&l.ID, &l.Name, &l.RootPath, &l.CreatedAt) + return l, err +} + +// ---------- books ---------- + +const bookCols = "id, library_id, path, title, format, file_size, mod_ts, page_count, state, error_msg, added_at" + +func (s *Store) InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) { + var id int64 + err := s.P.QueryRow(ctx, + `INSERT INTO books (library_id, path, title, format, file_size, mod_ts, page_count) + VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`, + libID, path, title, format, size, modTS, pageCount).Scan(&id) + return id, err +} + +func (s *Store) GetBook(ctx context.Context, id int64) (Book, error) { + var b Book + err := s.P.QueryRow(ctx, "SELECT "+bookCols+" FROM books WHERE id=$1", id).Scan( + &b.ID, &b.LibraryID, &b.Path, &b.Title, &b.Format, + &b.FileSize, &b.ModTS, &b.PageCount, &b.State, &b.ErrMsg, &b.AddedAt) + return b, err +} + +func (s *Store) ListBookMeta(ctx context.Context, libID int64) (map[string]BookMeta, error) { + rows, err := s.P.Query(ctx, + "SELECT id, path, file_size, mod_ts, format FROM books WHERE library_id=$1", libID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]BookMeta{} + for rows.Next() { + var m BookMeta + var path string + if err := rows.Scan(&m.ID, &path, &m.Size, &m.ModTS, &m.Format); err != nil { + return nil, err + } + out[path] = m + } + return out, rows.Err() +} + +func (s *Store) UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error { + _, err := s.P.Exec(ctx, + `UPDATE books SET file_size=$2, mod_ts=$3, page_count=$4, state='ready', error_msg='' WHERE id=$1`, + id, size, modTS, pageCount) + return err +} + +func (s *Store) DeleteBookByPath(ctx context.Context, libID int64, path string) error { + _, err := s.P.Exec(ctx, "DELETE FROM books WHERE library_id=$1 AND path=$2", libID, path) + return err +} + +func (s *Store) DeleteBook(ctx context.Context, id int64) error { + _, err := s.P.Exec(ctx, "DELETE FROM books WHERE id=$1", id) + return err +} + +func (s *Store) SetBookState(ctx context.Context, id int64, state, msg string) error { + _, err := s.P.Exec(ctx, "UPDATE books SET state=$2, error_msg=$3 WHERE id=$1", id, state, msg) + return err +} + +func (s *Store) ListBookIDs(ctx context.Context) ([]int64, error) { + rows, err := s.P.Query(ctx, "SELECT id FROM books") + if err != nil { + return nil, err + } + defer rows.Close() + var out []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + out = append(out, id) + } + return out, rows.Err() +} + +func (s *Store) ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]BookView, error) { + rows, err := s.P.Query(ctx, + `SELECT b.id, b.library_id, b.path, b.title, b.format, b.file_size, b.mod_ts, + b.page_count, b.state, b.error_msg, b.added_at, l.name, COALESCE(p.percent, 0) + FROM books b JOIN libraries l ON l.id = b.library_id + LEFT JOIN reading_progress p ON p.user_id = $4 AND p.library_id = b.library_id AND p.book_path = b.path + WHERE ($1 = 0 OR b.library_id = $1) + AND ($2 = '' OR lower(b.title) LIKE '%' || lower($2) || '%') + AND ($3 = '' OR b.path LIKE $3 || '%') + ORDER BY l.name, b.path`, libID, q, prefix, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []BookView + for rows.Next() { + var v BookView + err := rows.Scan(&v.ID, &v.LibraryID, &v.Path, &v.Title, &v.Format, + &v.FileSize, &v.ModTS, &v.PageCount, &v.State, &v.ErrMsg, &v.AddedAt, + &v.LibraryName, &v.Percent) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// ---------- progress ---------- + +func (s *Store) UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error { + _, err := s.P.Exec(ctx, + `INSERT INTO reading_progress (user_id, library_id, book_path, locator, percent, updated_at) + VALUES ($1,$2,$3,$4,$5,now()) + ON CONFLICT (user_id, library_id, book_path) + DO UPDATE SET locator=$4, percent=$5, updated_at=now()`, + userID, libID, bookPath, locator, percent) + return err +} + +func (s *Store) ListProgress(ctx context.Context, userID int64) ([]Progress, error) { + rows, err := s.P.Query(ctx, + `SELECT p.library_id, l.name, p.book_path, COALESCE(b.title, ''), p.locator, p.percent, p.updated_at + FROM reading_progress p JOIN libraries l ON l.id = p.library_id + LEFT JOIN books b ON b.library_id = p.library_id AND b.path = p.book_path + WHERE p.user_id = $1 ORDER BY p.updated_at DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Progress + for rows.Next() { + var pr Progress + if err := rows.Scan(&pr.LibraryID, &pr.LibraryName, &pr.BookPath, &pr.Title, + &pr.Locator, &pr.Percent, &pr.UpdatedAt); err != nil { + return nil, err + } + out = append(out, pr) + } + return out, rows.Err() +} + +func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath string) (Progress, error) { + var pr Progress + err := s.P.QueryRow(ctx, + `SELECT library_id, book_path, locator, percent, updated_at + FROM reading_progress WHERE user_id=$1 AND library_id=$2 AND book_path=$3`, + userID, libID, bookPath). + Scan(&pr.LibraryID, &pr.BookPath, &pr.Locator, &pr.Percent, &pr.UpdatedAt) + return pr, err +} +``` + +- [ ] **Step 6: 跑测试确认通过** + +Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v` +Expected: 2 个测试 PASS(无 DATABASE_URL 时 SKIP) + +- [ ] **Step 7: Commit** + +```bash +git add backend deploy && git commit -m "feat(backend): pg schema + store queries incl. path-keyed progress" +``` + +--- + +### Task 3: auth 包 — bcrypt、JWT、admin 种子 + +**Files:** +- Create: `backend/internal/auth/auth.go` +- Modify: `backend/internal/store/store.go`(不动 — CountUsers/CreateUser 已存在) +- Create: `backend/internal/seed/seed.go` +- Test: `backend/internal/auth/auth_test.go`, `backend/internal/seed/seed_test.go` + +**Interfaces:** +- Consumes: `store.Store`(Task 2) +- Produces: + +```go +auth.HashPassword(plain string) (string, error) +auth.CheckPassword(hash, plain string) bool +auth.Claims struct{ UID int64 `json:"uid"`; Role string `json:"role"`; jwt.RegisteredClaims } +auth.Sign(secret []byte, uid int64, role string) (string, error) +auth.Parse(secret []byte, token string) (*auth.Claims, error) +seed.Admin(ctx context.Context, s *store.Store, user, pass string) error // users 为空才建;user/pass 空则跳过;失败仅由调用方记日志 +``` + +- [ ] **Step 1: 写失败测试** + +`internal/auth/auth_test.go`: + +```go +package auth + +import ( + "strings" + "testing" + "time" +) + +func TestHashCheck(t *testing.T) { + h, err := HashPassword("hunter2") + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(h, "$2a$") || !CheckPassword(h, "hunter2") || CheckPassword(h, "wrong") { + t.Fatal("bcrypt wrong") + } +} + +func TestSignParse(t *testing.T) { + secret := []byte("k") + tok, err := Sign(secret, 7, "admin") + if err != nil { + t.Fatal(err) + } + c, err := Parse(secret, tok) + if err != nil || c.UID != 7 || c.Role != "admin" { + t.Fatalf("parse: %v %+v", err, c) + } + if _, err := Parse([]byte("other"), tok); err == nil { + t.Fatal("must reject wrong secret") + } + expired, _ := SignWithTTL(secret, 1, "member", -time.Hour) + if _, err := Parse(secret, expired); err == nil { + t.Fatal("must reject expired") + } + // alg 混淆攻击:none 必须拒 + if _, err := Parse(secret, "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1aWQiOjF9."); err == nil { + t.Fatal("must reject alg=none") + } +} +``` + +`internal/seed/seed_test.go`: + +```go +package seed + +import ( + "context" + "os" + "testing" + + "booklib/internal/db" + "booklib/internal/store" +) + +func TestSeedOnlyWhenEmpty(t *testing.T) { + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, _ := db.Connect(ctx, url) + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + s := store.New(p) + p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") + if err := Admin(ctx, s, "admin", "pw12345"); err != nil { + t.Fatal(err) + } + if err := Admin(ctx, s, "admin2", "pw12345"); err != nil { // 已有用户 → no-op + t.Fatal(err) + } + n, _ := s.CountUsers(ctx) + if n != 1 { + t.Fatalf("want 1 user got %d", n) + } + u, err := s.GetUserByName(ctx, "admin") + if err != nil || u.Role != "admin" { + t.Fatalf("bad admin: %+v %v", u, err) + } + if err := Admin(ctx, s, "", ""); err != nil { + t.Fatal("empty creds must no-op, got", err) + } +} +``` + +- [ ] **Step 2: 跑,确认编译失败** + +Run: `cd backend && go test ./internal/auth/ ./internal/seed/ -v` +Expected: FAIL — undefined + +- [ ] **Step 3: 实现** + +`internal/auth/auth.go`: + +```go +package auth + +import ( + "crypto/subtle" + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +var ErrToken = errors.New("invalid token") + +func HashPassword(plain string) (string, error) { + b, err := bcrypt.GenerateFromPassword([]byte(plain), 12) + return string(b), err +} + +func CheckPassword(hash, plain string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil +} + +type Claims struct { + UID int64 `json:"uid"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func Sign(secret []byte, uid int64, role string) (string, error) { + return SignWithTTL(secret, uid, role, 72*time.Hour) +} + +func SignWithTTL(secret []byte, uid int64, role string, ttl time.Duration) (string, error) { + t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UID: uid, Role: role, + RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl))}, + }) + return t.SignedString(secret) +} + +func Parse(secret []byte, token string) (*Claims, error) { + c := &Claims{} + parsed, err := jwt.ParseWithClaims(token, c, func(t *jwt.Token) (any, error) { + m, ok := t.Method.(*jwt.SigningMethodHMAC) + if !ok || subtle.ConstantTimeCompare([]byte(m.Alg()), []byte("HS256")) != 1 { + return nil, ErrToken + } + return secret, nil + }, jwt.WithValidMethods([]string{"HS256"})) + if err != nil || !parsed.Valid { + return nil, ErrToken + } + return c, nil +} +``` + +`internal/seed/seed.go`: + +```go +package seed + +import ( + "context" + "log" + + "booklib/internal/auth" + "booklib/internal/store" +) + +func Admin(ctx context.Context, s *store.Store, user, pass string) error { + if user == "" || pass == "" { + return nil + } + n, err := s.CountUsers(ctx) + if err != nil || n > 0 { + return err + } + h, err := auth.HashPassword(pass) + if err != nil { + return err + } + if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil { + log.Printf("seed admin %q may already exist: %v", user, err) + return nil + } + log.Printf("seeded initial admin user %q", user) + return nil +} +``` + +- [ ] **Step 4: 跑测试确认通过** + +Run: `cd backend && go test ./internal/auth/ -v && DATABASE_URL=... go test ./internal/seed/ -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend && git commit -m "feat(backend): bcrypt+jwt auth package and admin seeding" +``` + +--- + +### Task 4: Redis 包装 + 登录/me + JWT 中间件(限流、降级) + +**Files:** +- Create: `backend/internal/redispkg/redis.go` +- Create: `backend/internal/api/api.go`(api struct、错误体、中间件) +- Modify: `backend/internal/api/router.go`(去掉 `any` 垫片,注册 auth 路由) +- Create: `backend/internal/api/auth.go`(login/me) +- Test: `backend/internal/api/auth_test.go`, `backend/internal/redispkg/redis_test.go` + +**Interfaces:** +- Consumes: `store.Store`, `auth.*`, `config.Config` +- Produces: + +```go +redispkg.New(url string) *R // url=="" → 全 no-op 实例(降级即默认) +(*R) Get(ctx, key string) (string, bool) +(*R) Set(ctx, key, val string, ttl time.Duration) +(*R) IncrWindow(ctx, key string, ttl time.Duration) int // 禁用/故障时恒返 1(放行) +(*R) Lock(ctx, key string, ttl time.Duration) (unlock func(), ok bool) + +api 错误体: {"error":{"code":string,"message":string}};api.err(c, status, code, msg) +api 中间件设置 ctx key: "uid" int64 / "role" string;helpers api.uid(c)/api.isAdmin(c) +NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine // scanner 参数 Task 9 再加 +``` + +- [ ] **Step 1: 写失败测试 `auth_test.go`** + +```go +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "booklib/internal/auth" + "booklib/internal/db" + "booklib/internal/redispkg" + "booklib/internal/store" +) + +func setupAPI(t *testing.T) (*store.Store, http.Handler) { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, _ := db.Connect(ctx, url) + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + st := store.New(p) + p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") + h, _ := auth.HashPassword("pw12345") + _, err := st.CreateUser(ctx, "alice", h, "admin") + if err != nil { + t.Fatal(err) + } + _, err = st.CreateUser(ctx, "bob", h, "member") + if err != nil { + t.Fatal(err) + } + cfg := testCfg() + r := NewRouter(cfg, st, redispkg.New(os.Getenv("REDIS_URL"))) + return st, r +} + +func do(h http.Handler, method, path, token string, body any) *httptest.ResponseRecorder { + var r *bytes.Reader + if body != nil { + b, _ := json.Marshal(body) + r = bytes.NewReader(b) + } else { + r = bytes.NewReader(nil) + } + req := httptest.NewRequest(method, path, r) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + return w +} + +func TestLoginMe(t *testing.T) { + _, h := setupAPI(t) + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"}) + if w.Code != 200 { + t.Fatalf("login %d %s", w.Code, w.Body) + } + var tok struct{ Token string } + json.Unmarshal(w.Body.Bytes(), &tok) + if tok.Token == "" { + t.Fatal("no token") + } + w = do(h, "GET", "/api/auth/me", tok.Token, nil) + var me map[string]any + json.Unmarshal(w.Body.Bytes(), &me) + if w.Code != 200 || me["username"] != "alice" || me["role"] != "admin" { + t.Fatalf("me %d %s", w.Code, w.Body) + } + // 错密码 → 401 统一错误体 + w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "nope"}) + if w.Code != 401 { + t.Fatalf("want 401 got %d", w.Code) + } + // 无 token / 坏 token 访问受保护端点 → 401(me 已注册;books 路由 Task 10 才有) + if w = do(h, "GET", "/api/auth/me", "", nil); w.Code != 401 { + t.Fatal("me without token must 401") + } + if w = do(h, "GET", "/api/auth/me", "garbage", nil); w.Code != 401 { + t.Fatal("me with bad token must 401") + } +} + +func TestMemberCannotWriteUsers(t *testing.T) { + _, h := setupAPI(t) + tok, _ := auth.Sign([]byte("s3cret"), 2, "member") // bob — 注意: 必须走真实登录拿 token + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "bob", "password": "pw12345"}) + var v struct{ Token string } + json.Unmarshal(w.Body.Bytes(), &v) + tok = v.Token + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "eve", "password": "pw12345", "role": "member"}) + if w.Code != 403 { + t.Fatalf("member write users: want 403 got %d", w.Code) + } +} +``` + +`redispkg/redis_test.go`(no-op 实例必须永不报错): + +```go +package redispkg + +import ( + "context" + "testing" + "time" +) + +func TestDisabledIsSafe(t *testing.T) { + r := New("") + ctx := context.Background() + if _, ok := r.Get(ctx, "x"); ok { + t.Fatal("disabled Get must miss") + } + r.Set(ctx, "x", "y", time.Second) // 不 panic + if n := r.IncrWindow(ctx, "k", time.Second); n != 1 { + t.Fatal("disabled IncrWindow must allow") + } + un, ok := r.Lock(ctx, "lk", time.Second) + if !ok { + t.Fatal("disabled Lock must always acquire") + } + un() +} +``` + +- [ ] **Step 2: 跑,确认失败** + +Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/redispkg/ -v` +Expected: 编译失败 undefined: redispkg / NewRouter 签名不符 + +- [ ] **Step 3: 实现 redispkg** + +`internal/redispkg/redis.go`: + +```go +package redispkg + +import ( + "context" + "crypto/rand" + "encoding/hex" + "log" + "time" + + "github.com/redis/go-redis/v9" +) + +type R struct{ c *redis.Client } + +func New(url string) *R { + if url == "" { + return &R{} + } + opt, err := redis.ParseURL(url) + if err != nil { + log.Printf("bad REDIS_URL (%v): redis disabled", err) + return &R{} + } + return &R{c: redis.NewClient(opt)} +} + +func (r *R) Get(ctx context.Context, key string) (string, bool) { + if r.c == nil { + return "", false + } + v, err := r.c.Get(ctx, key).Result() + if err != nil { + return "", false // 故障=miss + } + return v, true +} + +func (r *R) Set(ctx context.Context, key, val string, ttl time.Duration) { + if r.c == nil { + return + } + if err := r.c.Set(ctx, key, val, ttl).Err(); err != nil { + log.Printf("redis set %s: %v", key, err) + } +} + +func (r *R) IncrWindow(ctx context.Context, key string, ttl time.Duration) int { + if r.c == nil { + return 1 + } + n, err := r.c.Incr(ctx, key).Result() + if err != nil { + return 1 + } + if n == 1 { + r.c.Expire(ctx, key, ttl) + } + return int(n) +} + +func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bool) { + noop := func() {} + if r.c == nil { + return noop, true + } + b := make([]byte, 8) + rand.Read(b) + tok := hex.EncodeToString(b) + ok, err := r.c.SetNX(ctx, key, tok, ttl).Result() + if err != nil || !ok { + return noop, false + } + return func() { + r.c.Eval(ctx, + "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", + []string{key}, tok) + }, true +} +``` + +- [ ] **Step 4: 实现 api struct/错误/中间件 + auth handlers,重写 NewRouter** + +`internal/api/api.go`: + +```go +package api + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "booklib/internal/auth" + "booklib/internal/config" + "booklib/internal/redispkg" + "booklib/internal/store" +) + +type api struct { + cfg *config.Config + st *store.Store + rdb *redispkg.R +} + +func err(c *gin.Context, status int, code, msg string) { + c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}}) +} + +func (a *api) authMw() gin.HandlerFunc { + return func(c *gin.Context) { + h := c.GetHeader("Authorization") + tok, ok := strings.CutPrefix(h, "Bearer ") + if !ok { + err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token") + return + } + cl, perr := auth.Parse(a.cfg.JWTSecret, tok) + if perr != nil { + err(c, http.StatusUnauthorized, "unauthorized", "invalid token") + return + } + c.Set("uid", cl.UID) + c.Set("role", cl.Role) + c.Next() + } +} + +func (a *api) adminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + if c.GetString("role") != "admin" { + err(c, http.StatusForbidden, "forbidden", "admin only") + return + } + c.Next() + } +} + +func uid(c *gin.Context) int64 { return c.GetInt64("uid") } +func isAdmin(c *gin.Context) bool { return c.GetString("role") == "admin" } +``` + +约定:包级错误函数叫 `err`;函数体内接收 error 一律命名 `perr/e/qerr`,避免遮蔽。`internal/api/auth.go`: + +```go +package api + +import ( + "context" + "errors" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + + "booklib/internal/auth" +) + +const loginWindow = time.Minute +const loginMax = 5 + +func (a *api) login(c *gin.Context) { + var req struct{ Username, Password string } + if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" { + err(c, http.StatusBadRequest, "bad_request", "username and password required") + return + } + if n := a.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax { + err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts") + return + } + u, qerr := a.st.GetUserByName(c, req.Username) + if qerr != nil { + if !errors.Is(qerr, pgx.ErrNoRows) { + log.Printf("db: %v", qerr) + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + // 用户不存在也走一次 bcrypt,防用户名枚举时序差 + auth.CheckPassword("$2a$12$000000000000000000000000000000000000000000000000000O", req.Password) + err(c, http.StatusUnauthorized, "unauthorized", "bad credentials") + return + } + if !auth.CheckPassword(u.PasswordHash, req.Password) { + err(c, http.StatusUnauthorized, "unauthorized", "bad credentials") + return + } + tok, serr := auth.Sign(a.cfg.JWTSecret, u.ID, u.Role) + if serr != nil { + err(c, http.StatusInternalServerError, "internal", "sign") + return + } + c.JSON(http.StatusOK, gin.H{"token": tok}) +} + +func (a *api) me(c *gin.Context) { + u, qerr := a.st.GetUserByID(c, uid(c)) + if qerr != nil { + err(c, http.StatusUnauthorized, "unauthorized", "no such user") + return + } + c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role}) +} +``` + +(auth.go 的 import 列表里不要 `context`,该文件未用到。) + +`internal/api/router.go` 整文件替换: + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "booklib/internal/config" + "booklib/internal/redispkg" + "booklib/internal/store" +) + +func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine { + gin.SetMode(gin.ReleaseMode) + a := &api{cfg: cfg, st: st, rdb: rdb} + r := gin.New() + r.Use(gin.Recovery()) + g := r.Group("/api") + g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") }) + g.POST("/auth/login", a.login) + + p := g.Group("", a.authMw()) + p.GET("/auth/me", a.me) + return r +} +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test ./internal/api/ ./internal/redispkg/ -v` +Expected: PASS(REDIS_URL 不给也应 PASS — no-op 路径) + +- [ ] **Step 6: Commit** + +```bash +git add backend && git commit -m "feat(backend): jwt middleware, login with redis rate limit, /auth/me" +``` + +--- + +### Task 5: 用户与库管理 API(★admin)+ 上传入库 + +**Files:** +- Create: `backend/internal/api/users.go`, `backend/internal/api/libraries.go` +- Create: `backend/internal/bookfile/bookfile.go`(先只要 `FormatFromExt` + `SafeName`,Task 7 补齐其余) +- Modify: `backend/internal/api/router.go` +- Test: `backend/internal/api/users_test.go`, `backend/internal/api/libraries_test.go`, `backend/internal/bookfile/bookfile_test.go` + +**Interfaces:** +- Consumes: Task 2–4 +- Produces: +`GET/POST/DELETE /api/users`, `GET/POST /api/libraries`, `POST /api/libraries/:id/upload`, `POST /api/libraries/:id/scan`(本任务先返回 501,Task 9 接线 scanner) +`bookfile.FormatFromExt(name string) string` — `.cbz→"cbz" .pdf→"pdf" .epub→"epub" .txt→"txt" .md→"md"`,其他 `"..."` → `""` +`bookfile.SafeName(s string) string` — 去目录、去控制符、trim、限长 200、保留空格中文 + +- [ ] **Step 1: 写失败测试(bookfile 先行,纯函数)** + +`internal/bookfile/bookfile_test.go`: + +```go +package bookfile + +import "testing" + +func TestFormatFromExt(t *testing.T) { + cases := map[string]string{ + "a.cbz": "cbz", "B.PDF": "pdf", "x.epub": "epub", "y.txt": "txt", "z.md": "md", + "w.rar": "", "noext": "", "cbr.cbR": "cbz", "tar.gz": "", + } + for in, want := range cases { + if got := FormatFromExt(in); got != want { + t.Errorf("FormatFromExt(%q)=%q want %q", in, got, want) + } + } +} + +func TestSafeName(t *testing.T) { + cases := map[string]string{ + "my book.cbz": "my book.cbz", + "../../etc/passwd": "passwd", + "/abs/name.pdf": "name.pdf", + "a\\b\\c.epub": "c.epub", + " spaced .txt ": "spaced .txt", + "con\ntl.bin": "cntl.bin", + "": "", + } + for in, want := range cases { + if got := SafeName(in); got != want { + t.Errorf("SafeName(%q)=%q want %q", in, got, want) + } + } +} +``` + +(`"tar.gz"→""`:扩展名是 `.gz` 不在白名单,正确。`"con\ntl.bin"` → base 后去 `\t` 得 `cntl.bin`。) + +- [ ] **Step 2: 跑确认失败;实现 `bookfile.go`** + +```go +package bookfile + +import ( + "path/filepath" + "strings" + "unicode" +) + +func FormatFromExt(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".cbz", ".zip": + return "cbz" + case ".pdf": + return "pdf" + case ".epub": + return "epub" + case ".txt": + return "txt" + case ".md": + return "md" + } + return "" +} + +func SafeName(s string) string { + s = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, s) + s = strings.ReplaceAll(s, "\\", "/") + s = filepath.Base(filepath.ToSlash(s)) + if s == "." || s == "/" { + return "" + } + s = strings.TrimSpace(s) + if len(s) > 200 { + s = strings.TrimSpace(s[:200]) + } + return s +} +``` + +Run: `go test ./internal/bookfile/ -v` → PASS。 + +- [ ] **Step 3: 写失败测试 users/libraries(API)** + +`internal/api/users_test.go`: + +```go +package api + +import ( + "encoding/json" + "testing" +) + +func adminToken(t *testing.T, h http.Handler) string { + t.Helper() + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": "alice", "password": "pw12345"}) + var v struct{ Token string } + json.Unmarshal(w.Body.Bytes(), &v) + return v.Token +} + +func TestUserCRUD(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + w := do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw12345", "role": "member"}) + if w.Code != 201 { + t.Fatalf("create %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/users", tok, nil) + var users []map[string]any + json.Unmarshal(w.Body.Bytes(), &users) + if len(users) != 3 { + t.Fatalf("list want 3 got %d: %s", len(users), w.Body) + } + carolID := findID(users, "carol") + // 重名 → 409 + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "carol", "password": "pw12345", "role": "member"}) + if w.Code != 409 { + t.Fatalf("dup want 409 got %d", w.Code) + } + // 弱密码 → 400 + w = do(h, "POST", "/api/users", tok, map[string]string{"username": "dave", "password": "1", "role": "member"}) + if w.Code != 400 { + t.Fatalf("weak want 400 got %d", w.Code) + } + // 不能删自己:先 me 拿 id + w = do(h, "GET", "/api/auth/me", tok, nil) + var me map[string]any + json.Unmarshal(w.Body.Bytes(), &me) + w = do(h, "DELETE", "/api/users/"+itoa(me["id"]), tok, nil) + if w.Code != 400 { + t.Fatalf("self-delete want 400 got %d %s", w.Code, w.Body) + } + // 删 carol → 204,再删 → 404 + w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil) + if w.Code != 204 { + t.Fatalf("delete %d %s", w.Code, w.Body) + } + w = do(h, "DELETE", "/api/users/"+itoa(carolID), tok, nil) + if w.Code != 404 { + t.Fatalf("redelete want 404 got %d", w.Code) + } +} + +func TestBadRoleRejected(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + w := do(h, "POST", "/api/users", tok, map[string]string{"username": "e", "password": "pw12345", "role": "god"}) + if w.Code != 400 { + t.Fatalf("bad role want 400 got %d", w.Code) + } +} +``` + +本文件小助手(放同文件末尾;`users_test.go` 需 import `strconv`、`net/http`): + +```go +func findID(rows []map[string]any, name string) float64 { + for _, r := range rows { + if r["username"] == name { + return r["id"].(float64) + } + } + return 0 +} +func itoa(v any) string { return strconv.FormatFloat(v.(float64), 'f', 0, 64) } +``` + +(users_test 内 `adminToken` 在 Task 10 将改为转发 `loginAs`,届时删。) + +`internal/api/libraries_test.go`: + +```go +package api + +import ( + "encoding/json" + "mime/multipart" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLibraryCreateListUpload(t *testing.T) { + _, h := setupAPI(t) + tok := adminToken(t, h) + root := t.TempDir() + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root}) + if w.Code != 201 { + t.Fatalf("create lib %d %s", w.Code, w.Body) + } + var lib map[string]any + json.Unmarshal(w.Body.Bytes(), &lib) + libID := itoa(lib["id"]) + w = do(h, "GET", "/api/libraries", tok, nil) + if !strings.Contains(w.Body.String(), `"comics"`) { + t.Fatalf("list: %s", w.Body) + } + // 相对路径 root 必须 400(前缀校验的根) + w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"}) + if w.Code != 400 { + t.Fatalf("relative root want 400 got %d", w.Code) + } + // 上传:白名单 + 防穿越 + 原子落盘 + body, mw := uploadBody("my 01.cbz", []byte("zipbytes")) + req := httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww := httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 202 { + t.Fatalf("upload %d %s", ww.Code, ww.Body) + } + if _, err := os.Stat(filepath.Join(root, "my 01.cbz")); err != nil { + t.Fatal("uploaded file missing:", err) + } + body, mw = uploadBody("../../evil.cbz", []byte("x")) + req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww = httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 202 { // 名字被清洗成 evil.cbz,落在 root 内 + t.Fatalf("sanitize upload %d", ww.Code) + } + if _, err := os.Stat(filepath.Join(root, "evil.cbz")); err != nil { + t.Fatal("evil upload not sanitized") + } + body, mw = uploadBody("virus.exe", []byte("x")) + req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Authorization", "Bearer "+tok) + ww = httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 400 { + t.Fatalf("bad ext want 400 got %d", ww.Code) + } +} + +func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writer) { + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + fw, _ := mw.CreateFormFile("file", filename) + fw.Write(content) + mw.Close() + return buf, mw +} +``` + +(`libraries_test.go` 需 import `bytes`;`httptest.NewRequest("POST", url, buf)` 直接收 `*bytes.Buffer`。) + +- [ ] **Step 4: 跑确认失败;实现 users.go / libraries.go / router 接线** + +先修 `auth_test.go` 的 setupAPI(upload 测试需要,libRoot 校验依赖 BooksDir;auth_test.go 补 import `path/filepath`): + +```go + cfg := testCfg() + cfg.BooksDir = filepath.Clean(os.TempDir()) + cfg.CacheDir = t.TempDir() + r := NewRouter(cfg, st, redispkg.New(os.Getenv("REDIS_URL"))) +``` + +`internal/api/users.go`: + +```go +package api + +import ( + "errors" + "log" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + + "booklib/internal/auth" +) + +func (a *api) listUsers(c *gin.Context) { + users, e := a.st.ListUsers(c) + if e != nil { + log.Printf("db: %v", e) + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(users)) + for _, u := range users { + out = append(out, gin.H{"id": u.ID, "username": u.Username, "role": u.Role, + "created_at": u.CreatedAt.Format(time.RFC3339)}) + } + c.JSON(http.StatusOK, out) +} + +func (a *api) createUser(c *gin.Context) { + var req struct{ Username, Password, Role string } + if c.ShouldBindJSON(&req) != nil { + err(c, http.StatusBadRequest, "bad_request", "json body required") + return + } + if req.Role != "admin" && req.Role != "member" { + err(c, http.StatusBadRequest, "bad_request", "role must be admin|member") + return + } + if len(req.Password) < 8 { + err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)") + return + } + h, e := auth.HashPassword(req.Password) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "hash") + return + } + id, e := a.st.CreateUser(c, req.Username, h, req.Role) + if e != nil { + if isUnique(e) { + err(c, http.StatusConflict, "exists", "username taken") + return + } + err(c, http.StatusBadRequest, "bad_request", "invalid input") + return + } + c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role}) +} + +func (a *api) deleteUser(c *gin.Context) { + id, e := strconv.ParseInt(c.Param("id"), 10, 64) + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "bad id") + return + } + if id == uid(c) { + err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself") + return + } + target, e := a.st.GetUserByID(c, id) + if e != nil { + if errors.Is(e, pgx.ErrNoRows) { + err(c, http.StatusNotFound, "not_found", "no such user") + return + } + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + if target.Role == "admin" { + n, _ := a.st.CountAdmins(c) // Task 2 已提供;防删光最后一个 admin + if n <= 1 { + err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin") + return + } + } + if e := a.st.DeleteUser(c, id); e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} +``` + +`isUnique`: + +```go +func isUnique(e error) bool { + var pgErr *pgconn.PgError + return errors.As(e, &pgErr) && pgErr.Code == "23505" +} +``` + +(`import "github.com/jackc/pgx/v5/pgconn"`。) + +`internal/api/libraries.go`: + +```go +package api + +import ( + "io" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "booklib/internal/bookfile" + "booklib/internal/store" +) + +// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验) +func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) { + root := filepath.Clean(lib.RootPath) + books := filepath.Clean(a.cfg.BooksDir) + if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) { + err(c, http.StatusForbidden, "forbidden", "library root outside books dir") + return "", false + } + return root, true +} + +func (a *api) listLibraries(c *gin.Context) { + libs, e := a.st.ListLibraries(c) + if e != nil { + log.Printf("db: %v", e) + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(libs)) + for _, l := range libs { + out = append(out, gin.H{"id": l.ID, "name": l.Name, "root_path": l.RootPath, + "created_at": l.CreatedAt.Format(time.RFC3339)}) + } + c.JSON(http.StatusOK, out) +} + +func (a *api) createLibrary(c *gin.Context) { + var req struct{ Name, RootPath string } + if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" { + err(c, http.StatusBadRequest, "bad_request", "name and root_path required") + return + } + if !filepath.IsAbs(req.RootPath) { + err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute") + return + } + id, e := a.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath)) + if e != nil { + if isUnique(e) { + err(c, http.StatusConflict, "exists", "root_path taken") + return + } + err(c, http.StatusBadRequest, "bad_request", "invalid input") + return + } + c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)}) +} + +func (a *api) getLibrary(c *gin.Context) (store.Library, bool) { + id, e := strconv.ParseInt(c.Param("id"), 10, 64) + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "bad id") + return store.Library{}, false + } + lib, e := a.st.GetLibrary(c, id) + if e != nil { + err(c, http.StatusNotFound, "not_found", "no such library") + return store.Library{}, false + } + return lib, true +} + +func (a *api) scanLibrary(c *gin.Context) { + if _, ok := a.getLibrary(c); !ok { + return + } + // Task 9 接线: go a.sc.ScanLibraryByID(...) + err(c, http.StatusNotImplemented, "not_ready", "scanner not wired yet") +} + +func (a *api) upload(c *gin.Context) { + lib, ok := a.getLibrary(c) + if !ok { + return + } + root, ok := a.libRoot(c, lib) + if !ok { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.UploadMaxMB<<20) + fh, e := c.FormFile("file") + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required") + return + } + name := bookfile.SafeName(fh.Filename) + if bookfile.FormatFromExt(name) == "" { + err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md") + return + } + dst, e := a.uniquePath(root, name) + if e != nil { + err(c, http.StatusForbidden, "forbidden", e.Error()) + return + } + src, e := fh.Open() + if e != nil { + err(c, http.StatusInternalServerError, "internal", "open upload") + return + } + defer src.Close() + tmp := dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36) + out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "create tmp") + return + } + if _, e := io.Copy(out, src); e != nil { + out.Close() + os.Remove(tmp) + err(c, http.StatusRequestEntityTooLarge, "too_large", "upload failed") + return + } + out.Close() + if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编 + os.Remove(tmp) + err(c, http.StatusInternalServerError, "internal", "rename") + return + } + c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))}) +} + +// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀 +func (a *api) uniquePath(root, name string) (string, error) { + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for i := 0; ; i++ { + cand := base + ext + if i > 0 { + cand = base + " (" + strconv.Itoa(i) + ")" + ext + } + p := filepath.Join(root, cand) + if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) || + !strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) { + return "", os.ErrInvalid + } + if _, e := os.Stat(p); os.IsNotExist(e) { + return p, nil + } else if e != nil { + return "", e + } + if i > 999 { + return "", os.ErrExist + } + } +} +``` + +(`filepath.Join` 本身 Clean,前缀检查是纵深防御,保留。) + +`internal/api/router.go` 的路由追加(在 `p.GET("/auth/me", a.me)` 后): + +```go + users := p.Group("/users", a.adminOnly()) + users.GET("", a.listUsers) + users.POST("", a.createUser) + users.DELETE("/:id", a.deleteUser) + + libs := p.Group("/libraries") + libs.GET("", a.listLibraries) + libs.POST("", a.adminOnly(), a.createLibrary) + libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary) + libs.POST("/:id/upload", a.adminOnly(), a.upload) +``` + +- [ ] **Step 5: 跑测试确认通过** + +Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/bookfile/ -v` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add backend && git commit -m "feat(backend): user + library admin API, atomic sanitized upload" +``` + +--- + +### Task 6: bookfile zip 层 — 页索引、自然排序、取页、hash + +**Files:** +- Create: `backend/internal/bookfile/zip.go`, `backend/internal/bookfile/hash.go` +- Test: `backend/internal/bookfile/zip_test.go` + +**Interfaces:** +- Consumes: Task 5 的 `bookfile` 包 +- Produces: + +```go +bookfile.PageIndex(f io.ReaderAt, size int64) ([]string, error) // 按自然排序的图片条目名;条目名不安全 → error +bookfile.ReadEntry(f io.ReaderAt, size int64, name string) ([]byte, error) +bookfile.NaturalLess(a, b string) bool +bookfile.Hash(size, modTS int64) string // sha256("size:mtime") hex 前 16 字符 +``` + +- [ ] **Step 1: 写失败测试** + +`internal/bookfile/zip_test.go`。测试内构造 zip 夹具(不落盘,bytes.Buffer + zip.Writer): + +```go +package bookfile + +import ( + "archive/zip" + "bytes" + "testing" +) + +func zipOf(t *testing.T, names ...string) *bytes.Reader { + t.Helper() + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for i, n := range names { + w, err := zw.Create(n) + if err != nil { + t.Fatal(err) + } + w.Write([]byte{byte(i)}) + } + zw.Close() + return bytes.NewReader(buf.Bytes()) +} + +func TestPageIndexSortAndFilter(t *testing.T) { + r := zipOf(t, "page10.jpg", "page2.jpg", "page1.jpg", "cover.PNG", "meta.xml", "sub/3.webp", "readme.txt") + idx, err := PageIndex(r, int64(r.Len())) + if err != nil { + t.Fatal(err) + } + want := []string{"cover.PNG", "page1.jpg", "page2.jpg", "page10.jpg", "sub/3.webp"} + if len(idx) != len(want) { + t.Fatalf("got %v", idx) + } + for i := range want { + if idx[i] != want[i] { + t.Fatalf("got %v want %v", idx, want) + } + } +} + +func TestPageIndexRejectsSlip(t *testing.T) { + for _, bad := range []string{"../evil.jpg", "/etc/passwd.jpg", "a\\..\\b.jpg"} { + r := zipOf(t, bad) + if _, err := PageIndex(r, int64(r.Len())); err == nil { + t.Fatalf("entry %q must be rejected", bad) + } + } +} + +func TestReadEntry(t *testing.T) { + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + w, _ := zw.Create("p1.jpg") + w.Write([]byte("jpegbytes")) + zw.Close() + r := bytes.NewReader(buf.Bytes()) + got, err := ReadEntry(r, int64(r.Len()), "p1.jpg") + if err != nil || string(got) != "jpegbytes" { + t.Fatalf("%q %v", got, err) + } + if _, err := ReadEntry(r, int64(r.Len()), "nope.jpg"); err == nil { + t.Fatal("missing entry must error") + } +} + +func TestNaturalLess(t *testing.T) { + pairs := [][2]string{{"2", "10"}, {"a2b", "a10b"}, {"007", "7"}, {"a", "b"}, {"A", "a"}} + for _, p := range pairs { + if !NaturalLess(p[0], p[1]) { + t.Errorf("NaturalLess(%q,%q) want true", p[0], p[1]) + } + if NaturalLess(p[1], p[0]) { + t.Errorf("NaturalLess(%q,%q) want false", p[1], p[0]) + } + } +} + +func TestHash(t *testing.T) { + if Hash(100, 200) == Hash(101, 200) || Hash(100, 200) != Hash(100, 200) { + t.Fatal("hash broken") + } + if len(Hash(1, 2)) != 16 { + t.Fatal("hash length") + } +} +``` + +注意 `{"007","7"}` 与 `{"A","a"}` 两个用例:数字相等时按原始串比较(`"007" < "7"`,字典序),纯 ASCII 大小写按字节序。 + +- [ ] **Step 2: 跑,确认失败** + +Run: `cd backend && go test ./internal/bookfile/ -run 'PageIndex|ReadEntry|Natural|Hash' -v` +Expected: 编译失败 undefined + +- [ ] **Step 3: 实现 `zip.go` 与 `hash.go`** + +`internal/bookfile/zip.go`: + +```go +package bookfile + +import ( + "archive/zip" + "errors" + "fmt" + "io" + "path" + "sort" + "strconv" + "strings" +) + +var ErrNotZip = errors.New("not a readable zip") +var ErrUnsafeZip = errors.New("unsafe zip entry") + +func unsafeEntry(n string) bool { + return strings.HasPrefix(n, "/") || strings.Contains(n, "..") || strings.ContainsRune(n, '\\') +} + +func isImage(name string) bool { + switch strings.ToLower(path.Ext(name)) { + case ".jpg", ".jpeg", ".png", ".webp", ".gif", ".avif": + return true + } + return false +} + +func PageIndex(f io.ReaderAt, size int64) ([]string, error) { + zr, err := zip.NewReader(f, size) + if err != nil { + return nil, ErrNotZip + } + var names []string + for _, zf := range zr.File { + if unsafeEntry(zf.Name) { + return nil, fmt.Errorf("%w: %s", ErrUnsafeZip, zf.Name) + } + if isImage(zf.Name) { + names = append(names, zf.Name) + } + } + sort.Slice(names, func(i, j int) bool { return NaturalLess(names[i], names[j]) }) + return names, nil +} + +func ReadEntry(f io.ReaderAt, size int64, name string) ([]byte, error) { + zr, err := zip.NewReader(f, size) + if err != nil { + return nil, ErrNotZip + } + for _, zf := range zr.File { + if zf.Name == name { + rc, err := zf.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, 64<<20)) + } + } + return nil, errors.New("no such entry") +} + +func NaturalLess(a, b string) bool { + i, j := 0, 0 + for i < len(a) && j < len(b) { + da, db := isDigit(a[i]), isDigit(b[j]) + switch { + case da && db: + si, sj := i, j + for i < len(a) && isDigit(a[i]) { + i++ + } + for j < len(b) && isDigit(b[j]) { + j++ + } + na, _ := strconv.Atoi(a[si:i]) + nb, _ := strconv.Atoi(b[sj:j]) + if na != nb { + return na < nb + } + if a[si:i] != b[sj:j] { + return a[si:i] < b[sj:j] + } + case !da && !db: + if a[i] != b[j] { + return a[i] < b[j] + } + i++ + j++ + default: + return da // 数字段排在字母前 + } + } + return j < len(b) +} + +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +``` + +`internal/bookfile/hash.go`: + +```go +package bookfile + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" +) + +func Hash(size, modTS int64) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d", size, modTS))) + return hex.EncodeToString(sum[:])[:16] +} +``` + +- [ ] **Step 4: 跑确认通过;Commit** + +Run: `cd backend && go test ./internal/bookfile/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): zip page index with natural sort, zip-slip rejection, content hash" +``` + +--- + +### Task 7: 封面提取 — CBZ 首页 / EPUB OPF + +**Files:** +- Create: `backend/internal/bookfile/cover.go` +- Test: `backend/internal/bookfile/cover_test.go` + +**Interfaces:** +- Consumes: Task 6 +- Produces: + +```go +bookfile.CBZCover(f io.ReaderAt, size int64) (img []byte, ext string, err error) +bookfile.EPUBCover(f io.ReaderAt, size int64) (img []byte, ext string, err error) // 无封面 → err +``` + +- [ ] **Step 1: 写失败测试** + +`internal/bookfile/cover_test.go`(内存构造真 epub:container.xml + OPF + cover.png): + +```go +package bookfile + +import ( + "archive/zip" + "bytes" + "net/url" + "testing" +) + +func TestCBZCoverIsFirstPage(t *testing.T) { + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + w, _ := zw.Create("02.jpg") + w.Write([]byte("second")) + w, _ = zw.Create("01.jpg") + w.Write([]byte("first")) + zw.Close() + r := bytes.NewReader(buf.Bytes()) + img, ext, err := CBZCover(r, int64(r.Len())) + if err != nil || string(img) != "first" || ext != ".jpg" { + t.Fatalf("%q %q %v", img, ext, err) + } +} + +func zipEntries(t *testing.T, kv map[string]string) *bytes.Reader { + t.Helper() + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for name, data := range kv { + w, _ := zw.Create(name) + w.Write([]byte(data)) + } + zw.Close() + return bytes.NewReader(buf.Bytes()) +} + +const containerXML = `` + +func opf(manifest, spine string) string { + return `` + + spine + `` + manifest + `` +} + +func TestEPUBCoverProperties(t *testing.T) { + r := zipEntries(t, map[string]string{ + "META-INF/container.xml": containerXML, + "OEBPS/content.opf": opf(``, ""), + "OEBPS/img/cover one.png": "PNGDATA", + }) + img, ext, err := EPUBCover(r, int64(r.Len())) + if err != nil || string(img) != "PNGDATA" || ext != ".png" { + t.Fatalf("%q %q %v", img, ext, err) + } +} + +func TestEPUBCoverMetaID(t *testing.T) { + r := zipEntries(t, map[string]string{ + "META-INF/container.xml": containerXML, + "OEBPS/content.opf": opf(``, ``), + "OEBPS/art.jpg": "JPGDATA", + }) + img, _, err := EPUBCover(r, int64(r.Len())) + if err != nil || string(img) != "JPGDATA" { + t.Fatalf("%q %v", img, err) + } +} + +func TestEPUBNoCover(t *testing.T) { + r := zipEntries(t, map[string]string{ + "META-INF/container.xml": containerXML, + "OEBPS/content.opf": opf(``, ""), + }) + if _, _, err := EPUBCover(r, int64(r.Len())); err == nil { + t.Fatal("want no-cover error") + } +} +``` + +- [ ] **Step 2: 跑确认失败;实现 `cover.go`** + +```go +package bookfile + +import ( + "archive/zip" + "encoding/xml" + "errors" + "io" + "net/url" + "path" + "strings" +) + +func CBZCover(f io.ReaderAt, size int64) ([]byte, string, error) { + idx, err := PageIndex(f, size) + if err != nil { + return nil, "", err + } + if len(idx) == 0 { + return nil, "", errors.New("no page images") + } + img, err := ReadEntry(f, size, idx[0]) + return img, strings.ToLower(path.Ext(idx[0])), err +} + +func readZipEntry(zr *zip.Reader, name string) ([]byte, error) { + for _, zf := range zr.File { + if zf.Name == name { + rc, err := zf.Open() + if err != nil { + return nil, err + } + defer rc.Close() + return io.ReadAll(io.LimitReader(rc, 8<<20)) + } + } + return nil, errors.New("entry not found: " + name) +} + +type opfDoc struct { + XMLName xml.Name `xml:"package"` + Metadata struct { + Meta []struct { + Name string `xml:"name,attr"` + Content string `xml:"content,attr"` + } `xml:"meta"` + } `xml:"metadata"` + Manifest struct { + Items []struct { + ID string `xml:"id,attr"` + Href string `xml:"href,attr"` + Properties string `xml:"properties,attr"` + MediaType string `xml:"media-type,attr"` + } `xml:"item"` + } `xml:"manifest"` +} + +func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) { + zr, err := zip.NewReader(f, size) + if err != nil { + return nil, "", ErrNotZip + } + cont, err := readZipEntry(zr, "META-INF/container.xml") + if err != nil { + return nil, "", err + } + var root struct { + XMLName xml.Name `xml:"container"` + RootFiles []struct { + FullPath string `xml:"full-path,attr"` + } `xml:"rootfiles>rootfile"` + } + if err := xml.Unmarshal(cont, &root); err != nil || len(root.RootFiles) == 0 { + return nil, "", errors.New("bad container.xml") + } + opfPath := root.RootFiles[0].FullPath + opfData, err := readZipEntry(zr, opfPath) + if err != nil { + return nil, "", err + } + var doc opfDoc + if err := xml.Unmarshal(opfData, &doc); err != nil { + return nil, "", errors.New("bad opf") + } + metaCoverID := "" + for _, m := range doc.Metadata.Meta { + if m.Name == "cover" { + metaCoverID = m.Content + } + } + href := "" + for _, it := range doc.Manifest.Items { + if strings.Contains(it.Properties, "cover-image") { + href = it.Href + break + } + if metaCoverID != "" && it.ID == metaCoverID { + href = it.Href + break + } + } + if href == "" { + for _, it := range doc.Manifest.Items { + if strings.HasPrefix(it.MediaType, "image/") && strings.Contains(strings.ToLower(it.ID), "cover") { + href = it.Href + break + } + } + } + if href == "" { + return nil, "", errors.New("no cover found") + } + unescaped, e := url.PathUnescape(href) + if e == nil { + href = unescaped + } + name := path.Join(path.Dir(opfPath), href) + if name == "." { + name = href + } + img, err := readZipEntry(zr, name) + if err != nil { + return nil, "", err + } + return img, strings.ToLower(path.Ext(name)), nil +} +``` + +(cover_test 中 `_ = url.PathUnescape` 那行不写,url 只在实现里 import。) + +- [ ] **Step 3: 跑确认通过;Commit** + +Run: `cd backend && go test ./internal/bookfile/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): cover extraction for cbz and epub (opf cover-image/meta)" +``` + +--- + +### Task 8: 磁盘缓存目录方案与清扫 + +**Files:** +- Create: `backend/internal/bookfile/cache.go` +- Modify: `backend/internal/store/store.go`(加 `BookHashes`) +- Test: `backend/internal/bookfile/cache_test.go` + +**Interfaces:** +- Consumes: Task 6 +- Produces: + +```go +bookfile.DirKey(id int64, hash string) string // "12-ab12cd34ef567890" +bookfile.CoverDir(cacheDir, key string) string // {cacheDir}/covers/{key}/ +bookfile.PagesDir(cacheDir, key string) string // {cacheDir}/pages/{key}/ +bookfile.SweepStale(cacheDir string, live map[string]bool) (int, error) // 删除 covers/ 与 pages/ 下不在 live 的整个子目录 +(*Store) BookHashes(ctx) (map[int64][2]int64, error) // id → {size, mod_ts},全库 +``` + +衍生文件永不算旧即永不原地改:更新的书 hash 变 → 新 key 目录;旧 key 目录由清扫删除。 + +- [ ] **Step 1: 写失败测试** + +`internal/bookfile/cache_test.go`: + +```go +package bookfile + +import ( + "os" + "path/filepath" + "testing" +) + +func mk(t *testing.T, cacheDir, kind, key, file string) { + t.Helper() + d := filepath.Join(cacheDir, kind, key) + os.MkdirAll(d, 0o755) + os.WriteFile(filepath.Join(d, file), []byte("x"), 0o644) +} + +func TestSweepStale(t *testing.T) { + dir := t.TempDir() + mk(t, dir, "covers", "1-aaa", "cover.jpg") + mk(t, dir, "covers", "2-bbb", "cover.png") + mk(t, dir, "pages", "1-aaa", "0.jpg") + mk(t, dir, "pages", "3-ccc", "0.jpg") + os.WriteFile(filepath.Join(dir, "covers", "stray.txt"), []byte("x"), 0o644) // 非目录,忽略 + + n, err := SweepStale(dir, map[string]bool{"1-aaa": true}) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("removed %d want 2", n) + } + if _, err := os.Stat(filepath.Join(dir, "covers", "1-aaa")); err != nil { + t.Fatal("live dir removed") + } + if _, err := os.Stat(filepath.Join(dir, "pages", "1-aaa")); err != nil { + t.Fatal("live pages removed") + } + if _, err := os.Stat(filepath.Join(dir, "covers", "2-bbb")); !os.IsNotExist(err) { + t.Fatal("stale cover remains") + } + // cacheDir 不存在 = 首次运行,不算错 + if n, err := SweepStale(filepath.Join(dir, "nope"), nil); n != 0 || err != nil { + t.Fatalf("%d %v", n, err) + } +} + +func TestPaths(t *testing.T) { + if DirKey(12, "ab") != "12-ab" { + t.Fatal("DirKey") + } + if CoverDir("/c", "12-ab") != filepath.Join("/c", "covers", "12-ab") { + t.Fatal("CoverDir") + } +} +``` + +- [ ] **Step 2: 跑确认失败;实现 `cache.go`** + +```go +package bookfile + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" +) + +func DirKey(id int64, hash string) string { return fmt.Sprintf("%d-%s", id, hash) } + +func CoverDir(cacheDir, key string) string { return filepath.Join(cacheDir, "covers", key) } +func PagesDir(cacheDir, key string) string { return filepath.Join(cacheDir, "pages", key) } + +func SweepStale(cacheDir string, live map[string]bool) (int, error) { + removed := 0 + for _, kind := range []string{"covers", "pages"} { + entries, err := os.ReadDir(filepath.Join(cacheDir, kind)) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue // 首次运行还没有该层目录,不算错 + } + return removed, err + } + for _, e := range entries { + if !e.IsDir() || live[e.Name()] { + continue + } + if err := os.RemoveAll(filepath.Join(cacheDir, kind, e.Name())); err != nil { + return removed, err + } + removed++ + } + } + return removed, nil +} +``` + +(目录结构约定:`covers/{id}-{hash}/cover{ext}`、`pages/{id}-{hash}/{n}{ext}` — scanner 与 content 端点共用。) + +`internal/store/store.go` 追加: + +```go +func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) { + rows, err := s.P.Query(ctx, "SELECT id, file_size, mod_ts FROM books") + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64][2]int64{} + for rows.Next() { + var id int64 + var v [2]int64 + if err := rows.Scan(&id, &v[0], &v[1]); err != nil { + return nil, err + } + out[id] = v + } + return out, rows.Err() +} +``` + +- [ ] **Step 3: 跑确认通过;Commit** + +Run: `cd backend && go test ./internal/bookfile/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): immutable cache dir scheme + stale sweep" +``` + +--- + +### Task 9: Scanner — diff、入库、封面、page_count、锁、清扫 + +**Files:** +- Create: `backend/internal/scanner/scanner.go` +- Modify: `backend/internal/api/router.go`(`NewRouter` 增 `sc *scanner.Scanner` 参数), `backend/internal/api/libraries.go`(`scanLibrary` 接真 scanner) +- Test: `backend/internal/scanner/scanner_test.go` + +**Interfaces:** +- Consumes: `store`(Task 2/8)、`bookfile`(6–8)、`redispkg`(4)、`config`(1) +- Produces: + +```go +scanner.New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner +(*Scanner) Run(ctx context.Context) // ticker: ScanInterval 全库轮扫 +(*Scanner) ScanLibraryByID(ctx context.Context, id int64) // 手动触发用 +(*Scanner) ScanLibrary(ctx context.Context, lib store.Library) +NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine // 最终签名 +``` + +行为规则(spec §3.1/§6.1):锁内执行;单本失败 → `state=error` 继续;删行不删进度(path 键控);扫描尾清扫缓存。**不删磁盘文件**(删文件只有 API DELETE)。 + +- [ ] **Step 1: 写失败测试 `scanner_test.go`(集成,依赖 PG;Redis 可为空走 no-op)** + +```go +package scanner + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "booklib/internal/bookfile" + "booklib/internal/config" + "booklib/internal/db" + "booklib/internal/redispkg" + "booklib/internal/store" +) + +// 返回 scanner、library、解析过符号链接的 root、以及一个满足 progress FK 的 uid +func setupLib(t *testing.T) (*Scanner, store.Library, string, int64) { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, _ := db.Connect(ctx, url) + db.Migrate(ctx, p) + st := store.New(p) + p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") + books := t.TempDir() + cache := t.TempDir() + resolved := mustResolve(t, books) + libID, err := st.CreateLibrary(ctx, "t", filepath.Join(resolved, "lib")) + if err != nil { + t.Fatal(err) + } + uid, err := st.CreateUser(ctx, "scantest", "h", "member") + if err != nil { + t.Fatal(err) + } + root := filepath.Join(resolved, "lib") + os.MkdirAll(filepath.Join(root, "series-a"), 0o755) + cfg := &config.Config{BooksDir: resolved, CacheDir: cache, ScanInterval: time.Minute} + lib, _ := st.GetLibrary(ctx, libID) + return New(st, cfg, redispkg.New(os.Getenv("REDIS_URL"))), lib, root, uid +} + +func mustResolve(t *testing.T, p string) string { + r, err := filepath.EvalSymlinks(p) // macOS 上 t.TempDir 是 /var→/private 符号链接 + if err != nil { + t.Fatal(err) + } + return r +} + +func writeCBZ(t *testing.T, path string, pages int) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0o755) + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for i := 1; i <= pages; i++ { + w, _ := zw.Create(fmt.Sprintf("%02d.jpg", i)) + w.Write(bytes.Repeat([]byte("JPG"), 100)) + } + zw.Close() + os.WriteFile(path, buf.Bytes(), 0o644) +} + +func TestScanFullLifecycle(t *testing.T) { + sc, lib, root, uid := setupLib(t) + ctx := context.Background() + writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 3) + os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hi"), 0o644) + sc.ScanLibrary(ctx, lib) + + meta, _ := sc.st.ListBookMeta(ctx, lib.ID) + if len(meta) != 2 { + t.Fatalf("want 2 books got %v", meta) + } + b, err := sc.st.GetBook(ctx, meta["series-a/vol_01.cbz"].ID) + if err != nil { + t.Fatal(err) + } + if b.Format != "cbz" || b.PageCount != 3 || b.Title != "vol 01" { + t.Fatalf("bad book %+v", b) + } + key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)) + covers, _ := os.ReadDir(bookfile.CoverDir(sc.cfg.CacheDir, key)) + if len(covers) == 0 { + t.Fatal("cover not built") + } + // 二次扫描:无变化 → 不动 + before := b.AddedAt + sc.ScanLibrary(ctx, lib) + b2, _ := sc.st.GetBook(ctx, b.ID) + if !b2.AddedAt.Equal(before) || b2.FileSize != b.FileSize { + t.Fatal("unchanged file must be untouched") + } + // 修改:page_count 变、hash 变、旧缓存被扫尾清掉 + writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 5) + os.Chtimes(filepath.Join(root, "series-a", "vol_01.cbz"), time.Now(), time.Now()) + sc.ScanLibrary(ctx, lib) + b3, _ := sc.st.GetBook(ctx, b.ID) + if b3.PageCount != 5 { + t.Fatalf("not updated: %+v", b3) + } + if _, err := os.Stat(bookfile.CoverDir(sc.cfg.CacheDir, key)); !os.IsNotExist(err) { + t.Fatal("stale cover cache remains") + } + // 删除:行没了,进度按 path 还在(spec §4) + if err := sc.st.UpsertProgress(ctx, uid, lib.ID, "notes.txt", []byte("{}"), 0.5); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(root, "notes.txt")); err != nil { + t.Fatal(err) + } + sc.ScanLibrary(ctx, lib) + if _, err := sc.st.GetBook(ctx, b.ID); err != nil { + t.Fatal("cbz vanished wrongly") + } + if _, ok := sc.st.ListBookMeta(ctx, lib.ID)["notes.txt"]; ok { + t.Fatal("deleted file still in db") + } + if pr, err := sc.st.GetProgress(ctx, uid, lib.ID, "notes.txt"); err != nil || pr.Percent != 0.5 { + t.Fatalf("progress must survive book removal: %+v %v", pr, err) + } +} + +func TestScanBrokenCBZStateError(t *testing.T) { + sc, lib, root, _ := setupLib(t) + ctx := context.Background() + os.WriteFile(filepath.Join(root, "bad.cbz"), []byte("not a zip"), 0o644) + sc.ScanLibrary(ctx, lib) + meta, _ := sc.st.ListBookMeta(ctx, lib.ID) + b, _ := sc.st.GetBook(ctx, meta["bad.cbz"].ID) + if b.State != "error" || b.ErrMsg == "" { + t.Fatalf("want error state, got %+v", b) + } +} +``` + +- [ ] **Step 2: 跑确认失败** + +Run: `cd backend && DATABASE_URL=... go test ./internal/scanner/ -v` → 编译失败 + +- [ ] **Step 3: 实现 scanner** + +`internal/scanner/scanner.go`: + +```go +package scanner + +import ( + "context" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "time" + + "booklib/internal/bookfile" + "booklib/internal/config" + "booklib/internal/redispkg" + "booklib/internal/store" +) + +type Scanner struct { + st *store.Store + cfg *config.Config + rdb *redispkg.R +} + +func New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner { + return &Scanner{st: st, cfg: cfg, rdb: rdb} +} + +func (s *Scanner) Run(ctx context.Context) { + t := time.NewTicker(s.cfg.ScanInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + libs, err := s.st.ListLibraries(ctx) + if err != nil { + log.Printf("scan: list libraries: %v", err) + continue + } + for _, l := range libs { + s.ScanLibrary(ctx, l) + } + } + } +} + +func (s *Scanner) ScanLibraryByID(ctx context.Context, id int64) { + lib, err := s.st.GetLibrary(ctx, id) + if err != nil { + log.Printf("scan: library %d: %v", id, err) + return + } + s.ScanLibrary(ctx, lib) +} + +func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) { + unlock, ok := s.rdb.Lock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute) + if !ok { + return // 别的副本在扫 + } + defer unlock() + + root, err := filepath.EvalSymlinks(filepath.Clean(lib.RootPath)) + if err != nil || !inside(s.cfg.BooksDir, root) { + log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath) + return + } + disk, err := walk(root) + if err != nil { + log.Printf("scan: walk %s: %v", root, err) + return + } + dbMeta, err := s.st.ListBookMeta(ctx, lib.ID) + if err != nil { + log.Printf("scan: list books: %v", err) + return + } + for rel, ds := range disk { + old, exists := dbMeta[rel] + delete(dbMeta, rel) + switch { + case !exists: + s.add(ctx, lib.ID, root, rel, ds) + case old.Size != ds.size || old.ModTS != ds.modTS: + s.update(ctx, lib.ID, old.ID, root, rel, ds) + } + } + for rel := range dbMeta { // 只剩被删的文件 + if err := s.st.DeleteBookByPath(ctx, lib.ID, rel); err != nil { + log.Printf("scan: delete %s: %v", rel, err) + } + } + s.sweepCache(ctx) +} + +type diskStat struct{ size, modTS int64 } + +func inside(booksDir, root string) bool { + b := filepath.Clean(booksDir) + return root == b || strings.HasPrefix(root, b+string(os.PathSeparator)) +} + +func walk(root string) (map[string]diskStat, error) { + out := map[string]diskStat{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + log.Printf("scan: walk %s: %v", p, err) + return nil // 单点失败不中断 + } + if d.IsDir() { + return nil + } + if bookfile.FormatFromExt(d.Name()) == "" { + return nil + } + info, err := d.Info() + if err != nil { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + out[filepath.ToSlash(rel)] = diskStat{info.Size(), info.ModTime().Unix()} + return nil + }) + return out, err +} + +func titleOf(rel string) string { + base := filepath.Base(rel) + return strings.TrimSpace(strings.ReplaceAll(strings.TrimSuffix(base, filepath.Ext(base)), "_", " ")) +} + +// cbz 完整性判定集中在 add/update:PageIndex 失败 → state=error。 +// InsertBook/UpdateBookFile 的 SQL 已把 state 重置为 ready(Task 2),无需显式清 error。 +func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds diskStat) { + format := bookfile.FormatFromExt(filepath.Base(rel)) + pageCount := 0 + var idxErr error + if format == "cbz" { + idx, err := s.zipIndex(root, rel) + pageCount = len(idx) + idxErr = err + } + id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount) + if err != nil { + log.Printf("scan: insert %s: %v", rel, err) + return + } + if idxErr != nil { + s.st.SetBookState(ctx, id, "error", idxErr.Error()) + return + } + s.cover(ctx, id, root, rel, format, ds) +} + +func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat) { + format := bookfile.FormatFromExt(filepath.Base(rel)) + pageCount := 0 + var idxErr error + if format == "cbz" { + idx, err := s.zipIndex(root, rel) + pageCount = len(idx) + idxErr = err + } + if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil { + log.Printf("scan: update %s: %v", rel, err) + return + } + if idxErr != nil { + s.st.SetBookState(ctx, bookID, "error", idxErr.Error()) + return + } + s.cover(ctx, bookID, root, rel, format, ds) +} + +func (s *Scanner) zipIndex(root, rel string) ([]string, error) { + f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + return nil, err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, err + } + return bookfile.PageIndex(f, st.Size()) +} + +// cover 失败(坏 epub、无图等)只 log — 书的 state 由 PageIndex 判定,封面缺了有占位 SVG 兜底 +func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) { + var img []byte + var ext string + var err error + switch format { + case "cbz": + img, ext, err = s.readCover(root, rel, bookfile.CBZCover) + case "epub": + img, ext, err = s.readCover(root, rel, bookfile.EPUBCover) + default: + return // pdf/txt/md 用占位 SVG,不落盘 + } + if err != nil { + log.Printf("scan: cover %s: %v", rel, err) + return + } + dir := bookfile.CoverDir(s.cfg.CacheDir, bookfile.DirKey(id, bookfile.Hash(ds.size, ds.modTS))) + if e := os.MkdirAll(dir, 0o755); e != nil { + log.Printf("scan: coverdir %s: %v", rel, e) + return + } + tmp := filepath.Join(dir, "cover"+ext+".tmp") + dst := filepath.Join(dir, "cover"+ext) + if e := os.WriteFile(tmp, img, 0o644); e == nil { + os.Rename(tmp, dst) + } +} + +func (s *Scanner) readCover(root, rel string, fn func(*os.File, int64) ([]byte, string, error)) ([]byte, string, error) { + f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + return nil, "", err + } + defer f.Close() + st, err := f.Stat() + if err != nil { + return nil, "", err + } + return fn(f, st.Size()) +} + +func (s *Scanner) sweepCache(ctx context.Context) { + hashes, err := s.st.BookHashes(ctx) + if err != nil { + return + } + live := map[string]bool{} + for id, v := range hashes { + live[bookfile.DirKey(id, bookfile.Hash(v[0], v[1]))] = true + } + if n, err := bookfile.SweepStale(s.cfg.CacheDir, live); err != nil { + log.Printf("scan: sweep: %v", err) + } else if n > 0 { + log.Printf("scan: swept %d stale cache dirs", n) + } +} +``` + +坏 cbz 的判定规则已合并在上面 `add`/`update` 终版中:`PageIndex` 失败(含 `ErrNotZip`、`ErrUnsafeZip`,Task 6 已定义)→ `state=error`;cover 提取失败只 log。 + +- [ ] **Step 4: 接线 API** + +`NewRouter` 加第 4 参 `sc *scanner.Scanner`,存进 `api` struct(`sc *scanner.Scanner`);`internal/api/libraries.go` 的 `scanLibrary` 整函数替换: + +```go +func (a *api) scanLibrary(c *gin.Context) { + lib, ok := a.getLibrary(c) + if !ok { + return + } + if _, ok := a.libRoot(c, lib); !ok { + return + } + go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID) + c.JSON(http.StatusAccepted, gin.H{"accepted": true}) +} +``` + +(api.go 补 import `"context"`。`NewRouter` 签名变为 4 参后,`setupAPI` 里最后一行同步改为: + +```go + rdb := redispkg.New(os.Getenv("REDIS_URL")) + r := NewRouter(cfg, st, rdb, scanner.New(st, cfg, rdb)) +``` + +(Task 10 会把 setupAPI 整体替换为 4 返回值终版,这里先保编译。)`api` struct 增加字段 `sc *scanner.Scanner`。 + +- [ ] **Step 5: 跑全部后端测试确认通过;Commit** + +Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./... -v` +Expected: 全 PASS + +```bash +git add backend && git commit -m "feat(backend): directory scanner — diff, error state, covers, cache sweep, redis lock" +``` + +--- + +### Task 10: 书籍列表 / 详情 / 删除 + +**Files:** +- Create: `backend/internal/api/books.go` +- Modify: `backend/internal/api/router.go`, 测试装配 +- Test: `backend/internal/api/books_test.go` + +**Interfaces:** +- Consumes: Task 2–9 +- Produces: +`GET /api/books?library=&q=&prefix=`、`GET /api/books/:id`、`DELETE /api/books/:id`(★) +`a.getBookRow(c, id) (store.Book, bool)`(404 已写)、`a.getLibRow(c, id) (store.Library, bool)`、`bookJSON(b store.Book, percent float64, libraryName string) gin.H`、`absBookPath(root string, b store.Book) (string, error)` — Task 11/12 复用 + +**测试装配变更(本任务统一做):** `setupAPI` 终版返回 4 值 `(*store.Store, *scanner.Scanner, http.Handler, string)`(最后一个是已解析符号链接的 booksDir),cfg 填真实目录: + +```go +func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) { + t.Helper() + url := os.Getenv("DATABASE_URL") + if url == "" { + t.Skip("DATABASE_URL not set") + } + ctx := context.Background() + p, _ := db.Connect(ctx, url) + if err := db.Migrate(ctx, p); err != nil { + t.Fatal(err) + } + st := store.New(p) + p.Exec(ctx, "DELETE FROM reading_progress; DELETE FROM books; DELETE FROM libraries; DELETE FROM users") + h, _ := auth.HashPassword("pw12345") + if _, err := st.CreateUser(ctx, "alice", h, "admin"); err != nil { + t.Fatal(err) + } + if _, err = st.CreateUser(ctx, "bob", h, "member"); err != nil { + t.Fatal(err) + } + booksParent := t.TempDir() + booksDir, err := filepath.EvalSymlinks(booksParent) // macOS 上 /var→/private,root 校验要用真实路径 + if err != nil { + t.Fatal(err) + } + cfg := testCfg() + cfg.BooksDir = booksDir + cfg.CacheDir = t.TempDir() + rdb := redispkg.New(os.Getenv("REDIS_URL")) + sc := scanner.New(st, cfg, rdb) + r := NewRouter(cfg, st, rdb, sc) + return st, sc, r, booksDir +} +``` + +既有 call site 全部改为按需要接收:`_, _, h, _ := setupAPI(t)`(共 5 处:auth_test 2、users_test 2、libraries_test 1);`TestMemberCannotWriteUsers` 里那行无效的 `tok, _ := auth.Sign(...)` 删除,`auth_test.go` 的 `booklib/internal/auth` import 随之删除。setupAPI 需补 import:`path/filepath`、`booklib/internal/scanner`。 + +- [ ] **Step 1: 写失败测试** + +`internal/api/books_test.go`(fixture 助手 `newLibrary/writeCBZ/scanNow/loginAs/adminToken` 定义在这里,Task 11/12 的测试文件复用): + +```go +package api + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "booklib/internal/store" +) + +func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, name string) (store.Library, string) { + t.Helper() + root := filepath.Join(booksDir, name) + os.MkdirAll(filepath.Join(root, "series-a"), 0o755) + w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root}) + if w.Code != 201 { + t.Fatalf("create lib %d %s", w.Code, w.Body) + } + var v struct { + ID int64 `json:"id"` + } + json.Unmarshal(w.Body.Bytes(), &v) + lib, err := st.GetLibrary(context.Background(), v.ID) + if err != nil { + t.Fatal(err) + } + return lib, root +} + +func writeCBZ(t *testing.T, path string, pages int) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0o755) + buf := &bytes.Buffer{} + zw := zip.NewWriter(buf) + for i := 1; i <= pages; i++ { + w, _ := zw.Create(i2name(i)) + w.Write(bytes.Repeat([]byte("IMG"), 64)) + } + zw.Close() + os.WriteFile(path, buf.Bytes(), 0o644) +} +func i2name(i int) string { return fmt.Sprintf("%02d.jpg", i) } + +func scanNow(t *testing.T, sc *scanner.Scanner, lib store.Library) { + t.Helper() + sc.ScanLibrary(context.Background(), lib) +} + +func TestBookListDetailDelete(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + + // member token + 进度前置数据 + do(h, "POST", "/api/users", atok, map[string]string{"username": "m2", "password": "pw12345", "role": "member"}) + mtok := loginAs(t, h, "m2", "pw12345") + + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + writeCBZ(t, filepath.Join(root, "series-a", "vol_01.cbz"), 4) + os.WriteFile(filepath.Join(root, "readme.txt"), []byte("hello world"), 0o644) + scanNow(t, sc, lib) + + w := do(h, "GET", "/api/books", mtok, nil) + var books []map[string]any + json.Unmarshal(w.Body.Bytes(), &books) + if w.Code != 200 || len(books) != 2 { + t.Fatalf("list %d %s", w.Code, w.Body) + } + var cbz map[string]any + for _, b := range books { + if b["format"] == "cbz" { + cbz = b + } + } + if cbz == nil { + t.Fatal("cbz missing") + } + if cbz["pages"].(float64) != 4 || cbz["library"] != "comics" || cbz["percent"].(float64) != 0 { + t.Fatalf("bad json %+v", cbz) + } + if !strings.HasPrefix(cbz["cover_url"].(string), "/api/books/") || !strings.Contains(cbz["cover_url"].(string), "?v=") { + t.Fatalf("cover_url %+v", cbz["cover_url"]) + } + if cbz["page_url_fmt"] == nil { + t.Fatalf("cbz must have page_url_fmt: %+v", cbz) + } + id := itoa(cbz["id"]) + + // detail + w = do(h, "GET", "/api/books/"+id, mtok, nil) + if w.Code != 200 { + t.Fatalf("detail %d", w.Code) + } + // 进度联动的断言在 Task 12(progress 端点此任务还不存在) + + // member 不能删 + w = do(h, "DELETE", "/api/books/"+id, mtok, nil) + if w.Code != 403 { + t.Fatalf("member delete want 403 got %d", w.Code) + } + // admin 删:行、文件、缓存目录都没;txt 文件保留 + w = do(h, "DELETE", "/api/books/"+id, atok, nil) + if w.Code != 204 { + t.Fatalf("admin delete %d %s", w.Code, w.Body) + } + if _, err := os.Stat(filepath.Join(root, "series-a", "vol_01.cbz")); !os.IsNotExist(err) { + t.Fatal("file not removed") + } + w = do(h, "GET", "/api/books", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 1 || books[0]["format"] != "txt" { + t.Fatalf("books after delete %+v", books) + } + // 过滤器 + w = do(h, "GET", "/api/books?library="+itoa(lib.ID)+"&q=readme&prefix=series/", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 0 { + t.Fatalf("prefix+q filter broken %+v", books) + } + w = do(h, "GET", "/api/books?q=readme", atok, nil) + json.Unmarshal(w.Body.Bytes(), &books) + if len(books) != 1 { + t.Fatalf("q broken %+v", books) + } +} +``` + +同文件末尾再定义 `loginAs`(users_test.go 里的 `adminToken` 保留原位,函数体替换为下一行的转发,删除其内联登录代码): + +```go +func loginAs(t *testing.T, h http.Handler, user, pass string) string { + t.Helper() + w := do(h, "POST", "/api/auth/login", "", map[string]string{"username": user, "password": pass}) + if w.Code != 200 { + t.Fatalf("login %s: %d", user, w.Code) + } + var v struct{ Token string } + json.Unmarshal(w.Body.Bytes(), &v) + return v.Token +} +``` + +`adminToken` 终版(users_test.go 中):`func adminToken(t *testing.T, h http.Handler) string { return loginAs(t, h, "alice", "pw12345") }`。 + +- [ ] **Step 2: 跑确认失败;实现 books.go + 路由** + +`internal/api/books.go`: + +```go +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "booklib/internal/bookfile" + "booklib/internal/store" +) + +func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) { + b, perr := a.st.GetBook(c, id) + if perr != nil { + err(c, http.StatusNotFound, "not_found", "no such book") + return store.Book{}, false + } + return b, true +} + +func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) { + id, e := strconv.ParseInt(c.Param("id"), 10, 64) + if e != nil { + err(c, http.StatusBadRequest, "bad_request", "bad id") + return store.Book{}, false + } + return a.getBookRow(c, id) +} + +func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) { + l, e := a.st.GetLibrary(c, id) + if e != nil { + err(c, http.StatusNotFound, "not_found", "no such library") + return store.Library{}, false + } + return l, true +} + +// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御) +func absBookPath(root string, b store.Book) (string, error) { + abs := filepath.Join(root, filepath.FromSlash(b.Path)) + if filepath.Clean(abs) != abs || !hasPrefixDir(abs, root) { + return "", os.ErrPermission + } + return abs, nil +} + +func hasPrefixDir(p, dir string) bool { + rel, err := filepath.Rel(filepath.Clean(dir), filepath.Clean(p)) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + +func bookJSON(b store.Book, percent float64, libraryName string) gin.H { + h := bookfile.Hash(b.FileSize, b.ModTS) + j := gin.H{ + "id": b.ID, "library_id": b.LibraryID, "path": b.Path, "title": b.Title, + "format": b.Format, "size": b.FileSize, "mtime": b.ModTS, "pages": b.PageCount, + "state": b.State, "error": b.ErrMsg, "added_at": b.AddedAt.Format(time.RFC3339), + "percent": percent, + "cover_url": fmt.Sprintf("/api/books/%d/cover?v=%s", b.ID, h), + } + if b.Format == "cbz" { + j["pages_url"] = fmt.Sprintf("/api/books/%d/pages", b.ID) + j["page_url_fmt"] = fmt.Sprintf("/api/books/%d/pages/%%d?v=%s", b.ID, h) + } else { + j["file_url"] = fmt.Sprintf("/api/books/%d/file?v=%s", b.ID, h) + } + if libraryName != "" { + j["library"] = libraryName + } + return j +} + +func (a *api) listBooks(c *gin.Context) { + libID, _ := strconv.ParseInt(c.Query("library"), 10, 64) + views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c)) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(views)) + for _, v := range views { + out = append(out, bookJSON(v.Book, v.Percent, v.LibraryName)) + } + c.JSON(http.StatusOK, out) +} + +func (a *api) getBook(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + p, _ := a.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent + lib, _ := a.st.GetLibrary(c, b.LibraryID) + c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name)) +} + +func (a *api) deleteBook(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + lib, ok := a.getLibRow(c, b.LibraryID) + if !ok { + return + } + root, ok := a.libRoot(c, lib) + if !ok { + return + } + abs, e := absBookPath(root, b) + if e != nil { + err(c, http.StatusForbidden, "forbidden", "unsafe path") + return + } + if e := os.Remove(abs); e != nil && !os.IsNotExist(e) { + err(c, http.StatusInternalServerError, "internal", "remove file") + return + } + key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)) + os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key)) + os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key)) + if e := a.st.DeleteBook(c, b.ID); e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} +``` + +`internal/api/router.go` 路由追加: + +```go + p.GET("/books", a.listBooks) + p.GET("/books/:id", a.getBook) + p.DELETE("/books/:id", a.adminOnly(), a.deleteBook) +``` + +- [ ] **Step 3: 跑确认通过;Commit** + +Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): book list/detail/delete with embedded progress + cache cleanup" +``` + +--- + +### Task 11: 内容端点 — cover / file(Range)/ pages / page + +**Files:** +- Create: `backend/internal/api/content.go` +- Modify: `backend/internal/api/router.go` +- Test: `backend/internal/api/content_test.go` + +**Interfaces:** +- Consumes: Task 6–10(`a.bookFromParam`、`absBookPath`、bookfile、redispkg) +- Produces: + +```go +GET /api/books/:id/cover → 缓存封面或内嵌占位 SVG;immutable +GET /api/books/:id/file → 原文件,Range + ETag(= bookfile.Hash),txt/md 带 charset +GET /api/books/:id/pages → {"count":n} (仅 cbz,否则 400) +GET /api/books/:id/pages/:n → 第 n 页(0-based),落盘缓存后吐;immutable +(*api) bookRoot(c, b) (store.Book, string, bool) // book+校验过的库 root,404/403 已写 +(*api) pageIndex(c, b store.Book, root string) ([]string, error) // redis pagesidx 缓存 7d +``` + +- [ ] **Step 1: 写失败测试** + +`internal/api/content_test.go`(fixture 复用 books_test 的 `newLibrary/writeCBZ/scanNow/loginAs/adminToken`): + +```go +package api + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" +) + +func serveFixture(t *testing.T) (http.Handler, string, string) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + lib, root := newLibrary(t, st, h, atok, booksDir, "comics") + writeCBZ(t, filepath.Join(root, "s", "one.cbz"), 3) + os.WriteFile(filepath.Join(root, "two.txt"), []byte("plain text body"), 0o644) + scanNow(t, sc, lib) + w := do(h, "GET", "/api/books?q=one", atok, nil) + var bs []map[string]any + json.Unmarshal(w.Body.Bytes(), &bs) + cbzID := itoa(bs[0]["id"]) + w = do(h, "GET", "/api/books?q=two", atok, nil) // 检索走 title(文件名去扩展名),不是 path + json.Unmarshal(w.Body.Bytes(), &bs) + txtID := itoa(bs[0]["id"]) + return h, cbzID, txtID +} + +func TestCoverCBZAndPlaceholder(t *testing.T) { + h, cbzID, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+cbzID+"/cover", tok, nil) + if w.Code != 200 || !strings.Contains(w.Header().Get("Content-Type"), "image/") { + t.Fatalf("cbz cover %d %s %q", w.Code, w.Body, w.Header().Get("Content-Type")) + } + if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") { + t.Fatal("cover must be immutable") + } + w = do(h, "GET", "/api/books/"+txtID+"/cover", tok, nil) + if w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" { + t.Fatalf("placeholder cover %d %q", w.Code, w.Header().Get("Content-Type")) + } + w = do(h, "GET", "/api/books/999999/cover", tok, nil) + if w.Code != 404 { + t.Fatalf("missing book cover want 404 got %d", w.Code) + } +} + +func TestPages(t *testing.T) { + h, cbzID, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil) + var v struct{ Count int } + json.Unmarshal(w.Body.Bytes(), &v) + if w.Code != 200 || v.Count != 3 { + t.Fatalf("pages %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/books/"+cbzID+"/pages/1", tok, nil) + if w.Code != 200 || !strings.Contains(w.Body.String(), "IMG") { + t.Fatalf("page 1 %d", w.Code) + } + if !strings.Contains(w.Header().Get("Cache-Control"), "immutable") { + t.Fatal("page must be immutable") + } + for _, bad := range []string{"4", "-1", "abc"} { + if w = do(h, "GET", "/api/books/"+cbzID+"/pages/"+bad, tok, nil); w.Code != 404 && w.Code != 400 { + t.Fatalf("pages/%s want 404/400 got %d", bad, w.Code) + } + } + if w = do(h, "GET", "/api/books/"+txtID+"/pages", tok, nil); w.Code != 400 { + t.Fatalf("pages on txt want 400 got %d", w.Code) + } + // 二次命中磁盘缓存(服务仍 200,字节一致) + w2 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil) + w3 := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil) + if w2.Code != 200 || w2.Body.String() != w3.Body.String() { + t.Fatal("page cache inconsistent") + } +} + +func TestFileRangeETag(t *testing.T) { + h, _, txtID := serveFixture(t) + tok := adminToken(t, h) + w := do(h, "GET", "/api/books/"+txtID+"/file", tok, nil) + if w.Code != 200 || w.Body.String() != "plain text body" { + t.Fatalf("file %d %q", w.Code, w.Body) + } + if w.Header().Get("ETag") == "" { + t.Fatal("no etag") + } + req := httptest.NewRequest("GET", "/api/books/"+txtID+"/file", nil) + req.Header.Set("Range", "bytes=0-4") + req.Header.Set("Authorization", "Bearer "+tok) + ww := httptest.NewRecorder() + h.ServeHTTP(ww, req) + if ww.Code != 206 || ww.Body.String() != "plain" { + t.Fatalf("range %d %q", ww.Code, ww.Body) + } +} +``` + +(`content_test.go` import 需含 `net/http/httptest`、`encoding/json`、`strings`、`os`、`path/filepath`、`testing`、`net/http`。) + +- [ ] **Step 2: 跑确认失败;实现 content.go** + +```go +package api + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "booklib/internal/bookfile" + "booklib/internal/store" +) + +const defaultCover = `` + +func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) { + lib, ok := a.getLibRow(c, b.LibraryID) + if !ok { + return "", false + } + return a.libRoot(c, lib) +} + +func (a *api) immutable(c *gin.Context) { + c.Header("Cache-Control", "public, max-age=31536000, immutable") +} + +func (a *api) serveCover(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + a.immutable(c) + dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) + if entries, err := os.ReadDir(dir); err == nil && len(entries) > 0 { + http.ServeFile(c.Writer, c.Request, filepath.Join(dir, entries[0].Name())) + return + } + c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover)) +} + +func (a *api) serveFile(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + root, ok := a.bookRoot(c, b) + if !ok { + return + } + abs, perr := absBookPath(root, b) + if perr != nil { + err(c, http.StatusForbidden, "forbidden", "unsafe path") + return + } + c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`) + c.Header("Cache-Control", "private, must-revalidate") + http.ServeFile(c.Writer, c.Request, abs) +} +``` + +(`deleteBook` 里同样用 `abs, perr :=`,原代码已是此形态,保持一致。) + +CBZ 打开助手 + 页索引(redis 缓存)+ pages/page: + +```go +func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) { + abs, perr := absBookPath(root, b) + if perr != nil { + err(c, http.StatusForbidden, "forbidden", "unsafe path") + return nil, 0, false + } + f, perr := os.Open(abs) + if perr != nil { + err(c, http.StatusNotFound, "not_found", "file missing on disk") + return nil, 0, false + } + st, perr := f.Stat() + if perr != nil { + f.Close() + err(c, http.StatusInternalServerError, "internal", "stat") + return nil, 0, false + } + return f, st.Size(), true +} + +func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) { + hash := bookfile.Hash(b.FileSize, b.ModTS) + key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash) + if v, ok := a.rdb.Get(c, key); ok { + return strings.Split(v, "\n"), nil + } + f, size, ok := a.openBook(c, b, root) + if !ok { + return nil, os.ErrNotExist + } + defer f.Close() + idx, e := bookfile.PageIndex(f, size) + if e != nil { + return nil, e + } + a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour) + return idx, nil +} + +func (a *api) pagesCount(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + if b.Format != "cbz" { + err(c, http.StatusBadRequest, "bad_request", "pages only for cbz") + return + } + root, ok := a.bookRoot(c, b) + if !ok { + return + } + idx, e := a.pageIndex(c, b, root) + if e != nil { + err(c, http.StatusUnprocessableEntity, "broken", e.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"count": len(idx)}) +} + +func (a *api) page(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + if b.Format != "cbz" { + err(c, http.StatusBadRequest, "bad_request", "pages only for cbz") + return + } + n, e := strconv.Atoi(c.Param("n")) + if e != nil || n < 0 { + err(c, http.StatusBadRequest, "bad_request", "bad page number") + return + } + root, ok := a.bookRoot(c, b) + if !ok { + return + } + idx, e := a.pageIndex(c, b, root) + if e != nil { + err(c, http.StatusUnprocessableEntity, "broken", e.Error()) + return + } + if n >= len(idx) { + err(c, http.StatusNotFound, "not_found", "no such page") + return + } + ext := strings.ToLower(filepath.Ext(idx[n])) + dir := bookfile.PagesDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))) + dst := filepath.Join(dir, strconv.Itoa(n)+ext) + if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,rename 原子) + f, size, ok := a.openBook(c, b, root) + if !ok { + return + } + defer f.Close() + data, e := bookfile.ReadEntry(f, size, idx[n]) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "extract page") + return + } + os.MkdirAll(dir, 0o755) + tmp := dst + ".tmp" + if e := os.WriteFile(tmp, data, 0o644); e == nil { + os.Rename(tmp, dst) + } + } + a.immutable(c) + http.ServeFile(c.Writer, c.Request, dst) +} +``` + +router 追加: + +```go + p.GET("/books/:id/cover", a.serveCover) + p.GET("/books/:id/file", a.serveFile) + p.GET("/books/:id/pages", a.pagesCount) + p.GET("/books/:id/pages/:n", a.page) +``` + +- [ ] **Step 3: 跑确认通过;Commit** + +Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./internal/api/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): cover/file/pages endpoints — immutable URLs, Range, disk+redis caches" +``` + +--- + +### Task 12: 进度端点 + +**Files:** +- Create: `backend/internal/api/progress.go` +- Modify: `backend/internal/api/router.go` +- Test: `backend/internal/api/progress_test.go` + +**Interfaces:** +- Consumes: Task 2/10 +- Produces:`PUT /api/books/:id/progress`、`GET /api/progress` + +- [ ] **Step 1: 写失败测试** + +`internal/api/progress_test.go`: + +```go +package api + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestProgressRoundTrip(t *testing.T) { + st, sc, h, booksDir := setupAPI(t) + atok := adminToken(t, h) + do(h, "POST", "/api/users", atok, map[string]string{"username": "p1", "password": "pw12345", "role": "member"}) + mtok := loginAs(t, h, "p1", "pw12345") + + lib, root := newLibrary(t, st, h, atok, booksDir, "prog") + os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644) + scanNow(t, sc, lib) + w := do(h, "GET", "/api/books?q=a", atok, nil) // title="a"(文件名去 .txt) + var bs []map[string]any + json.Unmarshal(w.Body.Bytes(), &bs) + id := itoa(bs[0]["id"]) + + w = do(h, "PUT", "/api/books/"+id+"/progress", mtok, + map[string]any{"locator": map[string]int{"page": 12}, "percent": 0.42}) + if w.Code != 204 { + t.Fatalf("put %d %s", w.Code, w.Body) + } + w = do(h, "GET", "/api/progress", mtok, nil) + var rows []map[string]any + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 1 || rows[0]["percent"].(float64) != 0.42 || rows[0]["title"] != "a" { + t.Fatalf("get %s", w.Body) + } + if rows[0]["locator"].(map[string]any)["page"].(float64) != 12 { + t.Fatalf("locator %s", w.Body) + } + // upsert 覆盖 + do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"locator": map[string]int{"page": 20}, "percent": 0.8}) + w = do(h, "GET", "/api/progress", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 1 || rows[0]["percent"].(float64) != 0.8 { + t.Fatalf("upsert %s", w.Body) + } + // 别人的进度不可见 + w = do(h, "GET", "/api/progress", atok, nil) + json.Unmarshal(w.Body.Bytes(), &rows) + if len(rows) != 0 { + t.Fatalf("leak %s", w.Body) + } + // 校验 + w = do(h, "PUT", "/api/books/"+id+"/progress", mtok, map[string]any{"percent": 1.5}) + if w.Code != 400 { + t.Fatalf("bad percent want 400 got %d", w.Code) + } + w = do(h, "PUT", "/api/books/999999/progress", mtok, map[string]any{"percent": 0.5}) + if w.Code != 404 { + t.Fatalf("bad book want 404 got %d", w.Code) + } + // 列表 percent 内嵌(Task 10 预告的断言在此兑现) + w = do(h, "GET", "/api/books", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &bs) + if bs[0]["percent"].(float64) != 0.8 { + t.Fatalf("list percent %+v", bs[0]) + } + // 删书,进度按 path 保留,重扫后 0.8 回来 + do(h, "DELETE", "/api/books/"+id, atok, nil) + os.WriteFile(filepath.Join(root, "a.txt"), []byte("aaa"), 0o644) + scanNow(t, sc, lib) + w = do(h, "GET", "/api/books?q=a", mtok, nil) + json.Unmarshal(w.Body.Bytes(), &bs) + if bs[0]["percent"].(float64) != 0.8 { + t.Fatalf("progress did not survive delete+rescan: %+v", bs[0]) + } +} +``` + +(progress_test 不用 context,import 列表不要 `context`。) + +- [ ] **Step 2: 跑确认失败;实现 progress.go** + +```go +package api + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +func (a *api) putProgress(c *gin.Context) { + b, ok := a.bookFromParam(c) + if !ok { + return + } + var req struct { + Locator json.RawMessage `json:"locator"` + Percent float64 `json:"percent"` + } + if e := c.ShouldBindJSON(&req); e != nil { + err(c, http.StatusBadRequest, "bad_request", "json body required") + return + } + if req.Percent < 0 || req.Percent > 1 { + err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]") + return + } + if len(req.Locator) == 0 { + req.Locator = []byte("{}") + } + if !json.Valid(req.Locator) { + err(c, http.StatusBadRequest, "bad_request", "locator must be valid json") + return + } + if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + c.Status(http.StatusNoContent) +} + +func (a *api) listProgress(c *gin.Context) { + rows, e := a.st.ListProgress(c, uid(c)) + if e != nil { + err(c, http.StatusInternalServerError, "internal", "db error") + return + } + out := make([]gin.H, 0, len(rows)) + for _, p := range rows { + out = append(out, gin.H{ + "library_id": p.LibraryID, "library": p.LibraryName, "path": p.BookPath, + "title": p.Title, "locator": json.RawMessage(p.Locator), + "percent": p.Percent, "updated_at": p.UpdatedAt.Format(time.RFC3339), + }) + } + c.JSON(http.StatusOK, out) +} +``` + +router 追加: + +```go + p.PUT("/books/:id/progress", a.putProgress) + p.GET("/progress", a.listProgress) +``` + +- [ ] **Step 3: 跑确认通过;Commit** + +Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS + +```bash +git add backend && git commit -m "feat(backend): reading progress upsert + listing, path-keyed survival" +``` + +--- + +### Task 13: main 装配 + 优雅退出 + 全量回归 + +**Files:** +- Create: `backend/cmd/server/main.go` +- Modify: `backend/internal/config/config.go`(Load 单测) +- Test: `backend/internal/config/config_test.go` + +**Interfaces:** +- Consumes: 全部 +- Produces: 可执行 `/server`,env 驱动;`NewRouter` 最终签名不变。 + +- [ ] **Step 1: config 单测(先失败)** + +`internal/config/config_test.go`: + +```go +package config + +import ( + "testing" + "time" +) + +func TestLoad(t *testing.T) { + t.Setenv("JWT_SECRET", "") + if _, err := Load(); err == nil { + t.Fatal("missing JWT_SECRET must fail") + } + t.Setenv("JWT_SECRET", "x") + t.Setenv("SCAN_INTERVAL_SEC", "abc") + if _, err := Load(); err == nil { + t.Fatal("bad interval must fail") + } + t.Setenv("SCAN_INTERVAL_SEC", "30") + t.Setenv("DATABASE_URL", "postgres://x") + c, err := Load() + if err != nil { + t.Fatal(err) + } + if c.ScanInterval != 30*time.Second || c.BooksDir != "/data/books" || c.Addr != ":8080" { + t.Fatalf("%+v", c) + } +} +``` + +- [ ] **Step 2: 实现 main.go** + +`cmd/server/main.go`: + +```go +package main + +import ( + "context" + "errors" + "log" + "net/http" + "os/signal" + "syscall" + "time" + + "booklib/internal/api" + "booklib/internal/config" + "booklib/internal/db" + "booklib/internal/redispkg" + "booklib/internal/scanner" + "booklib/internal/seed" + "booklib/internal/store" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config: %v", err) + } + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + p, err := db.Connect(ctx, cfg.DatabaseURL) + if err != nil { + log.Fatalf("db connect: %v", err) + } + defer p.Close() + if err := db.Migrate(ctx, p); err != nil { + log.Fatalf("migrate: %v", err) + } + st := store.New(p) + if err := seed.Admin(ctx, st, cfg.AdminUser, cfg.AdminPassword); err != nil { + log.Fatalf("seed: %v", err) + } + rdb := redispkg.New(cfg.RedisURL) + sc := scanner.New(st, cfg, rdb) + go sc.Run(ctx) + + srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc), + ReadHeaderTimeout: 10 * time.Second} + go func() { + log.Printf("listening on %s", cfg.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("serve: %v", err) + } + }() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Printf("shutdown: %v", err) + } +} +``` + +- [ ] **Step 3: PG 不可用 → 503(spec §9 降级)** + +`internal/api/api.go` 追加(`import` 补 `errors`、`io`、`log`、`net`、`syscall`、`github.com/jackc/pgx/v5/pgxpool`): + +```go +// dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500 +func dbErr(c *gin.Context, e error) { + log.Printf("db: %v", e) + status, code := http.StatusInternalServerError, "internal" + if errors.Is(e, syscall.ECONNREFUSED) || errors.Is(e, io.ErrUnexpectedEOF) || + errors.Is(e, net.ErrClosed) || errors.Is(e, pgxpool.ErrClosedPool) { + status, code = http.StatusServiceUnavailable, "unavailable" + } + err(c, status, code, "db error") +} +``` + +把所有 handler 中 `"internal", "db error"` 的 500 分支替换为 `dbErr(c, <该处的 error 变量>)`:auth.go(login 1 处)、users.go(listUsers/createUser/deleteUser 各 1 处)、libraries.go(listLibraries)、books.go(listBooks/deleteBook)、content.go 无 DB 500 分支、progress.go(putProgress/listProgress)。替换后 `go test ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。 + +- [ ] **Step 4: 全量回归 + 本地起服烟测** + +```bash +cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test ./... +mkdir -p /tmp/libtest/books/demo && echo hello > /tmp/libtest/books/demo/a.txt +cd backend && JWT_SECRET=devsecret DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \ + REDIS_URL=redis://localhost:6380 ADMIN_USER=admin ADMIN_PASSWORD=adminpw12 BOOKS_DIR=/tmp/libtest/books \ + CACHE_DIR=/tmp/libtest/cache go run ./cmd/server & +sleep 2 +curl -s localhost:8080/api/healthz # ok +TOK=$(curl -s localhost:8080/api/auth/login -H 'content-type: application/json' -d '{"username":"admin","password":"adminpw12"}' | sed -E 's/.*"token":"([^"]+)".*/\1/') +curl -s localhost:8080/api/libraries -H "authorization: Bearer $TOK" -H 'content-type: application/json' -d '{"name":"demo","root_path":"/tmp/libtest/books/demo"}' +curl -s -X POST "localhost:8080/api/libraries/1/scan" -H "authorization: Bearer $TOK" +sleep 2 && curl -s "localhost:8080/api/books" -H "authorization: Bearer $TOK" # 含 a.txt +kill %1 +``` + +Expected: 全 PASS + 末行输出含 `"title":"a"`。 + +- [ ] **Step 5: Commit** + +```bash +git add backend && git commit -m "feat(backend): main wiring, graceful shutdown, local smoke green" +``` + +--- + +### Task 14: Docker Compose + nginx 收口 + 端到端冒烟 + +**Files:** +- Create: `docker-compose.yml`, `.env.example`, `deploy/Dockerfile.api`, `deploy/Dockerfile.web`, `deploy/nginx.conf`, `deploy/web-dist/index.html`(占位) +- Create: `scripts/smoke.sh` +- Modify: `deploy/docker-compose.dev.yml`(不动,仍供本地测试) + +**Interfaces:** +- Consumes: Task 13 的可运行服务 +- Produces: `docker compose up -d --scale api=2` 起 web/api×2/pg/redis;`scripts/smoke.sh` 一条命令验收整个后端 + +- [ ] **Step 1: 部署文件** + +`deploy/Dockerfile.api`: + +```dockerfile +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server + +FROM alpine:3.20 +RUN adduser -D -H app +COPY --from=build /server /server +USER app +EXPOSE 8080 +ENTRYPOINT ["/server"] +``` + +`deploy/nginx.conf`: + +```nginx +server { + listen 80; + client_max_body_size 200m; + resolver 127.0.0.11 valid=10s; + + location /api/ { + set $upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本 + proxy_pass $upstream; # 无 URI 部分:保留 /api 前缀转发 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location / { + root /usr/share/nginx/html; + try_files $uri /index.html; + } +} +``` + +`deploy/Dockerfile.web` + `deploy/web-dist/index.html`(Plan 2 用真 SPA 产物替换 web-dist 构建步骤): + +```dockerfile +FROM nginx:1.27-alpine +COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf +COPY deploy/web-dist /usr/share/nginx/html +``` + +```html +booklib

backend up — frontend lands in Plan 2.

+``` + +`docker-compose.yml`: + +```yaml +services: + web: + build: { context: ., dockerfile: deploy/Dockerfile.web } + ports: ["8080:80"] + depends_on: [api] + api: + build: { context: ., dockerfile: deploy/Dockerfile.api } + environment: + DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET} + ADMIN_USER: ${ADMIN_USER} + ADMIN_PASSWORD: ${ADMIN_PASSWORD} + BOOKS_DIR: /data/books + CACHE_DIR: /data/cache + SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60} + volumes: + - ./library:/data/books + - cache:/data/cache + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_started } + postgres: + image: postgres:16-alpine + environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib } + volumes: [pgdata:/var/lib/postgresql/data] + healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 } + redis: + image: redis:7-alpine + command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"] + # 故意无 volume:redis 里全是可再生数据(spec §6.2) +volumes: + pgdata: + cache: +``` + +`.env.example`: + +``` +JWT_SECRET=change-me-openssl-rand-hex-32 +ADMIN_USER=admin +ADMIN_PASSWORD=change-me-min-8 +SCAN_INTERVAL_SEC=60 +``` + +- [ ] **Step 2: 冒烟脚本 `scripts/smoke.sh`** + +```bash +#!/usr/bin/env bash +set -euo pipefail +BASE=${BASE:-http://localhost:8080} +API=$BASE/api +J=(-H 'content-type: application/json') +[ -f .env ] && set -a && . ./.env && set +a + +say(){ echo "smoke: $1"; } +die(){ echo "SMOKE FAIL: $1"; exit 1; } +tokfor(){ curl -fsS "$API/auth/login" "${J[@]}" -d "{\"username\":\"$1\",\"password\":\"$2\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/'; } + +say "healthz" +curl -fsS "$API/healthz" >/dev/null || die "healthz down" + +say "login" +TOK=$(tokfor "$ADMIN_USER" "$ADMIN_PASSWORD") +[ -n "$TOK" ] || die "no token" +AUTH="authorization: Bearer $TOK" + +say "member user + role enforcement" +curl -fsS "$API/users" "${J[@]}" -H "$AUTH" -d '{"username":"smoke","password":"smokepw123","role":"member"}' >/dev/null || die "create member" +MTOK=$(tokfor smoke smokepw123) +code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/users" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"username":"x","password":"xpw12345","role":"member"}') +[ "$code" = 403 ] || die "member write not blocked ($code)" + +say "library + bad-ext upload rejected + good upload + scan" +mkdir -p library/smoke-books +LID=$(curl -fsS "$API/libraries" "${J[@]}" -H "$AUTH" -d '{"name":"smoke","root_path":"/data/books/smoke-books"}' | sed -E 's/.*"id":([0-9]+).*/\1/') +printf 'x' > library_upload_note.txt +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=virus.exe" && die "bad ext upload must fail" || true +printf 'hello smoke book' > library_upload_note.txt +curl -fsS -o /dev/null "$API/libraries/$LID/upload" -H "$AUTH" -F "file=@library_upload_note.txt;filename=note.txt" || die "upload failed" +curl -fsS -o /dev/null -X POST "$API/libraries/$LID/scan" -H "$AUTH" || die "scan trigger" +found="" +for _ in $(seq 30); do + if curl -fsS "$API/books?library=$LID" -H "$AUTH" | grep -q '"path":"note.txt"'; then found=1; break; fi + sleep 1 +done +[ -n "$found" ] || die "book not indexed after 30s" + +say "read + progress roundtrip" +BID=$(curl -fsS "$API/books?library=$LID" -H "$AUTH" | sed -E 's/.*"id":([0-9]+).*/\1/') +curl -fsS "$API/books/$BID/file" -H "$AUTH" | grep -q "hello smoke book" || die "file body" +code=$(curl -s -o /dev/null -w '%{http_code}' -X PUT "$API/books/$BID/progress" "${J[@]}" -H "authorization: Bearer $MTOK" -d '{"locator":{"scroll":0.5},"percent":0.5}') +[ "$code" = 204 ] || die "progress put $code" +curl -fsS "$API/progress" -H "authorization: Bearer $MTOK" | grep -q '"percent":0.5' || die "progress read" + +say "immutable cache header" +COVER=$(curl -fsS "$API/books/$BID" -H "$AUTH" | sed -E 's/.*"cover_url":"([^"]+)".*/\1/') +curl -fsS -o /dev/null -D - "$BASE$COVER" -H "$AUTH" | grep -qi 'cache-control:.*immutable' || die "cover not immutable" + +say "delete book → file gone from host dir" +curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete" +[ ! -f library/smoke-books/note.txt ] || die "file survived delete" + +say "ALL SMOKE TESTS PASSED" +``` + +- [ ] **Step 3: 跑通** + +```bash +cp .env.example .env && sed -i '' -E "s/change-me-openssl-rand-hex-32/$(openssl rand -hex 32)/; s/^ADMIN_PASSWORD=.*/ADMIN_PASSWORD=smokeadmin1/" .env +mkdir -p library +docker compose up -d --build --scale api=2 +sleep 3 && bash scripts/smoke.sh +docker compose down -v && rm -f library_upload_note.txt +``` + +Expected: `ALL SMOKE TESTS PASSED`。(Linux 上 `sed -i` 去掉 `''` 参数。) + +- [ ] **Step 4: Commit** + +```bash +git add docker-compose.yml .env.example deploy scripts && git commit -m "feat(deploy): compose w/ nginx api-prefix ingress, scaled stateless api, e2e smoke" +``` + +--- + +## Self-Review(计划完成后) + +1. **Spec 覆盖**:spec §5 全部端点 → Task 4/5/10/11/12;§6 三层缓存的前两层 → Task 4/8/9/11(第三层 SW 在 Plan 2);§3.1 上传+扫描合一 → Task 5/9;§7 安全 → Task 5/6/10/11;§10 测试 → 各任务 TDD;§11 部署 → Task 14。 +2. **占位符扫描**:全文 grep `TBD|TODO|panic\(|见下方|定稿|修正后` 无残留;写作期自我纠偏已全部合并进代码,每段代码即终版。 +3. **类型一致性**:`bookfile.Hash/DirKey/CoverDir/PagesDir/SweepStale/PageIndex/ReadEntry/CBZCover/EPUBCover`、`store.*`、`redispkg.*` 签名在各任务间已对齐;`setupAPI` 4 返回值版本是唯一真源。 diff --git a/docs/superpowers/specs/2026-09-04-book-comic-library-design.md b/docs/superpowers/specs/2026-09-04-book-comic-library-design.md new file mode 100644 index 0000000..68d1a1d --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-book-comic-library-design.md @@ -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 `;标 ★ 的仅 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。