Files
book-comic-library/docs/superpowers/plans/2026-09-04-backend.md
T

123 KiB
Raw Blame History

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 <jwt>。
  • 写操作(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

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
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:

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)):

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
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 包,后续任务全部依赖):
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:

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:

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 行为在此锁死)
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:

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,完整给出):

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
}

// ---------- 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
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:
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:

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:

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:

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:

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
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:
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
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 实例必须永不报错):

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:

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:

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:

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 整文件替换:

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
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:

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
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:

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):

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:

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):

	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:

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:

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:

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) 后):

	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
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:
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):

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:

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:

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

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:
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):

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 = `<?xml version="1.0"?><container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>`

func opf(manifest, spine string) string {
	return `<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf"><metadata>` +
		spine + `</metadata><manifest>` + manifest + `</manifest></package>`
}

func TestEPUBCoverProperties(t *testing.T) {
	r := zipEntries(t, map[string]string{
		"META-INF/container.xml": containerXML,
		"OEBPS/content.opf": opf(`<item id="c" href="img/cover%20one.png" properties="cover-image" media-type="image/png"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
		"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(`<item id="cv" href="art.jpg" media-type="image/jpeg"/><item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, `<meta name="cover" content="cv"/>`),
		"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(`<item id="t" href="t.xhtml" media-type="application/xhtml+xml"/>`, ""),
	})
	if _, _, err := EPUBCover(r, int64(r.Len())); err == nil {
		t.Fatal("want no-cover error")
	}
}
  • Step 2: 跑确认失败;实现 cover.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

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:
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:

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
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 追加:

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

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:
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)
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:

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 整函数替换:

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 里最后一行同步改为:

	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

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 填真实目录:

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 的测试文件复用):

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 保留原位,函数体替换为下一行的转发,删除其内联登录代码):

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:

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 路由追加:

	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

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:
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):

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
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`

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:

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 追加:

	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

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:

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
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 追加:

	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

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:

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:

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):

// 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: 全量回归 + 本地起服烟测
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
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:

FROM golang:1.26-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:

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 构建步骤):

FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY deploy/web-dist /usr/share/nginx/html
<!doctype html><title>booklib</title><p>backend up — frontend lands in Plan 2.</p>

docker-compose.yml:

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
#!/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: 跑通
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
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 返回值版本是唯一真源。