Files
book-comic-library/docs/superpowers/plans/2026-09-14-backend-hardening.md

53 KiB
Raw Permalink Blame History

Backend Hardening 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: Make schema changes safe via ordered migrations, fix 17 confirmed defects, restructure business logic into internal/ with port interfaces, and introduce a CI workflow.

Architecture: Three sequential batches. Batch A builds the migration system and CI foundation. Batch B fixes bugs B1–B13 with reproduction tests. Batch C performs structural reorganization (ports/store split/media/upload/scanner) with contract tests and fake-based unit tests. Each batch keeps tests green throughout.

Tech Stack: Go 1.26, gin, pgx/v5, go-redis/v9, PostgreSQL 16, Redis 7, GitHub Actions (Gitea Actions compatible), embedded SQL migrations with pg advisory lock.

Global Constraints

  • Branch: fix/backend-hardening (already created).
  • CHANGELOG entries in docs/CHANGELOG.md: English line then Chinese line on consecutive lines, different entries separated by blank line.
  • docs/README.md and docs/README_zh.md stay content-equivalent; update both in same change.
  • API contract: zero changes except B6/B7/B8 error semantics (recorded in changelog).
  • Migration: self-hosted embedded SQL + schema_migrations + pg advisory lock. No down migrations.
  • Refactor: small consumer-side interfaces in internal/ports, hand-written fakes in internal/ports/portsfake, no DI framework, no mock generators.
  • CI: standard GitHub Actions syntax in .github/workflows/; Gitea Actions compatible. Runner-less until Gitea runner registered; local gate mandatory before each batch merge.
  • Local gate before each batch merge: dev compose PG+Redis up, then go vet ./... && gofmt -l . && go test -p 1 -count=1 ./... confirming 0 skip, plus scripts/smoke.sh.

Part A — Migration System + CI + Quick Fixes

Task 1: Migration System

Files:

  • Create: backend/internal/db/migrations/0001_baseline.sql
  • Modify: backend/internal/db/db.go
  • Delete: backend/internal/db/schema.sql (after copying to migrations)

Interfaces:

  • Consumes: pgxpool.Pool from db.Connect

  • Produces: db.Migrate(ctx, pool) error — same signature, different internals; schema_migrations table auto-created

  • Step 1: Create baseline migration file

Copy current backend/internal/db/schema.sql content verbatim into backend/internal/db/migrations/0001_baseline.sql. Content is all CREATE TABLE IF NOT EXISTS and CREATE INDEX IF NOT EXISTS statements for users, libraries, books, reading_progress, bookmarks.

  • Step 2: Rewrite db.go with migration system

Replace backend/internal/db/db.go entirely with the new migration system:

package db

import (
	"context"
	"embed"
	"fmt"
	"io/fs"
	"log"
	"regexp"
	"sort"
	"strings"

	"github.com/jackc/pgx/v5/pgxpool"
)

//go:embed migrations
var migrationsFS embed.FS

const advisoryLockKey int64 = 0x424C4D49 // "BLMI"

var migrationNameRe = regexp.MustCompile(`^\d{4}_[a-z0-9_]+\.sql$`)

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 {
	// 1. Acquire advisory lock — serializes concurrent replicas.
	if _, err := p.Exec(ctx, "SELECT pg_advisory_lock($1)", advisoryLockKey); err != nil {
		return fmt.Errorf("advisory lock: %w", err)
	}
	defer func() {
		if _, err := p.Exec(ctx, "SELECT pg_advisory_unlock($1)", advisoryLockKey); err != nil {
			log.Printf("advisory unlock: %v", err)
		}
	}()

	// 2. Create tracking table.
	if _, err := p.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
		version BIGINT PRIMARY KEY, name TEXT NOT NULL,
		applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
		return fmt.Errorf("create schema_migrations: %w", err)
	}

	// 3. Read + validate embedded files.
	entries, err := fs.ReadDir(migrationsFS, "migrations")
	if err != nil {
		return fmt.Errorf("read migrations dir: %w", err)
	}
	var files []string
	for _, e := range entries {
		name := e.Name()
		if !migrationNameRe.MatchString(name) {
			panic(fmt.Sprintf("invalid migration filename: %q", name))
		}
		files = append(files, name)
	}
	sort.Strings(files)

	// 4. Baseline: empty schema_migrations + books exists → mark 0001 applied.
	var count int
	if err := p.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil {
		return fmt.Errorf("count migrations: %w", err)
	}
	if count == 0 {
		var hasBooks bool
		if err := p.QueryRow(ctx, "SELECT to_regclass('books') IS NOT NULL").Scan(&hasBooks); err != nil {
			return fmt.Errorf("check books: %w", err)
		}
		if hasBooks && len(files) > 0 && strings.HasPrefix(files[0], "0001_") {
			if _, err := p.Exec(ctx,
				"INSERT INTO schema_migrations (version, name) VALUES ($1,$2)",
				1, files[0]); err != nil {
				return fmt.Errorf("baseline: %w", err)
			}
			log.Printf("migration baseline: %s (existing db)", files[0])
			files = files[1:]
		}
	}

	// 5. Load applied set.
	applied := map[int64]bool{}
	rows, err := p.Query(ctx, "SELECT version FROM schema_migrations")
	if err != nil {
		return fmt.Errorf("list applied: %w", err)
	}
	defer rows.Close()
	for rows.Next() {
		var v int64
		if err := rows.Scan(&v); err != nil {
			return fmt.Errorf("scan: %w", err)
		}
		applied[v] = true
	}
	if err := rows.Err(); err != nil {
		return err
	}

	// 6. Apply pending, each in own transaction.
	for _, name := range files {
		v := parseVersion(name)
		if applied[v] {
			continue
		}
		sql, err := fs.ReadFile(migrationsFS, "migrations/"+name)
		if err != nil {
			return fmt.Errorf("read %s: %w", name, err)
		}
		tx, err := p.Begin(ctx)
		if err != nil {
			return err
		}
		if _, err := tx.Exec(ctx, string(sql)); err != nil {
			tx.Rollback(ctx)
			return fmt.Errorf("exec %s: %w", name, err)
		}
		if _, err := tx.Exec(ctx,
			"INSERT INTO schema_migrations (version, name) VALUES ($1,$2)", v, name); err != nil {
			tx.Rollback(ctx)
			return err
		}
		if err := tx.Commit(ctx); err != nil {
			return err
		}
		log.Printf("migration applied: %s", name)
	}
	return nil
}

func parseVersion(name string) int64 {
	parts := strings.SplitN(name, "_", 2)
	var v int64
	fmt.Sscanf(parts[0], "%d", &v)
	return v
}
  • Step 3: Delete old schema.sql
git rm backend/internal/db/schema.sql
  • Step 4: Verify build + tests
cd backend && go build ./...
DATABASE_URL=postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable \
  go test -p 1 -count=1 ./...
  • Step 5: Verify baseline on existing DB

On a DB with old schema (no schema_migrations):

  1. Start app → verify SELECT * FROM schema_migrations has one row (version=1).
  2. Verify existing data untouched (SELECT count(*) FROM books same as before).
  3. Verify no DDL re-executed (no errors in log).
  • Step 6: Commit
git add backend/internal/db/migrations/0001_baseline.sql backend/internal/db/db.go
git rm backend/internal/db/schema.sql
git commit -m "feat(db): ordered migration system with advisory lock and baseline detection"

Task 2: CI Workflow

Files:

  • Create: .github/workflows/ci.yml

  • Step 1: Create workflow

name: CI
on:
  push:
    branches: [master, 'fix/**', 'feat/**']
  pull_request:
    branches: [master]
jobs:
  backend:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: {POSTGRES_USER: booklib, POSTGRES_PASSWORD: booklib, POSTGRES_DB: booklib}
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 5s --health-timeout 3s --health-retries 10
      redis:
        image: redis:7
        ports: ['6379:6379']
        options: --health-cmd "redis-cli ping" --health-interval 5s --health-timeout 3s --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: {go-version-file: backend/go.mod}
      - run: |
          cd backend && OUT=$(gofmt -l .) && [ -z "$OUT" ] || { echo "gofmt:"; echo "$OUT"; exit 1; }
      - run: cd backend && go vet ./...
      - run: cd backend && go test -p 1 -count=1 ./...
        env:
          DATABASE_URL: postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable
          REDIS_URL: redis://localhost:6379/0
  frontend:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: {node-version: 20}
      - run: cd frontend && npm ci && npm run check
  • Step 2: Validate (optional)
go run github.com/rhysd/actionlint/cmd/actionlint@latest .github/workflows/ci.yml
  • Step 3: Commit
git add .github/workflows/ci.yml
git commit -m "ci: add GitHub Actions workflow (Gitea Actions compatible)"

Task 3: Fix B14 — log.Fatalf bypasses shutdown

Files:

  • Modify: backend/cmd/webui/main.go:43-55

  • Step 1: Replace serve goroutine + wait logic

Replace the block from srv := through <-ctx.Done() in main.go:

	serveErr := make(chan error, 1)
	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) {
			serveErr <- err
		}
		close(serveErr)
	}()
	select {
	case <-ctx.Done():
	case err := <-serveErr:
		if err != nil {
			log.Printf("serve: %v", err)
		}
	}
	stop()
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("shutdown: %v", err)
	}
  • Step 2: Build + smoke
cd backend && go build ./cmd/webui && scripts/smoke.sh
  • Step 3: Commit
git add backend/cmd/webui/main.go
git commit -m "fix(main): channel-based serve error instead of log.Fatalf in goroutine (B14)"

Task 4: Fix B15 — Config validation

Files:

  • Modify: backend/internal/config/config.go

  • Modify: backend/internal/config/config_test.go

  • Step 1: Add failing tests to config_test.go

func TestLoadDatabaseURLRequired(t *testing.T) {
	t.Setenv("JWT_SECRET", "x")
	t.Setenv("DATABASE_URL", "")
	if _, err := Load(); err == nil {
		t.Fatal("empty DATABASE_URL must fail")
	}
}

func TestLoadDatabaseURLMalformed(t *testing.T) {
	t.Setenv("JWT_SECRET", "x")
	t.Setenv("DATABASE_URL", "not a url")
	if _, err := Load(); err == nil {
		t.Fatal("malformed DATABASE_URL must fail")
	}
}
  • Step 2: Run to verify failure
cd backend && go test -run "TestLoadDatabase" -v ./internal/config/

Expected: TestLoadDatabaseURLRequired FAIL (empty URL currently accepted).

  • Step 3: Add validation in config.Load

Before the return &Config{...} in Load(), add:

	dbURL := env("DATABASE_URL", "")
	if dbURL == "" {
		return nil, fmt.Errorf("DATABASE_URL is required")
	}
	if _, perr := pgxpool.ParseConfig(dbURL); perr != nil {
		return nil, fmt.Errorf("DATABASE_URL: %w", perr)
	}
	if env("REDIS_URL", "") == "" {
		log.Printf("redis disabled: rate-limit/scan-lock/page-cache off")
	}

Add imports: "log", "github.com/jackc/pgx/v5/pgxpool".

  • Step 4: Run tests to verify pass
cd backend && go test -v ./internal/config/
  • Step 5: Commit
git add backend/internal/config/
git commit -m "fix(config): validate DATABASE_URL, log redis disabled (B15)"

Task 5: Fix B17 — smoke.sh outdated root_path

Files:

  • Modify: scripts/smoke.sh

  • Step 1: Remove root_path from library creation JSON

Find the curl POST to /libraries (around line 32) and change:

-d '{"name":"smoke","root_path":"/data/books/smoke-books"}'

to:

-d '{"name":"smoke"}'
  • Step 2: Verify
scripts/smoke.sh
  • Step 3: Commit
git add scripts/smoke.sh
git commit -m "fix(smoke): drop ignored root_path field, align with API contract (B17)"

Task 6: Batch A docs

Files:

  • Modify: docs/CHANGELOG.md

  • Modify: docs/README.md, docs/README_zh.md

  • Step 1: Prepend CHANGELOG entries

## [Unreleased]

### Changed
- Add ordered migration system with schema_migrations tracking and pg advisory lock. Existing databases auto-baselined.
- 新增有序迁移系统,通过 schema_migrations 表和 pg advisory lock 实现安全多副本 schema 演进,已有数据库自动基线化。

### Fixed
- Serve goroutine log.Fatalf replaced with channel-based shutdown (B14).
- 服务 goroutine 的 log.Fatalf 改为 channel 通知,确保优雅关停(B14)。
- DATABASE_URL validated at startup; empty REDIS_URL logs clear message (B15).
- 启动时校验 DATABASE_URL;空 REDIS_URL 记录明确日志(B15)。
- smoke.sh aligned with current API contract (B17).
- smoke.sh 对齐当前 API 契约(B17)。

### Added
- CI workflow (.github/workflows/ci.yml) for GitHub/Gitea Actions.
- CI 工作流,兼容 GitHub Actions 和 Gitea Actions。
  • Step 2: Update README dual versions

Add "Schema Migration" section (how to add NNNN_description.sql, never modify applied files, no down migrations, baseline auto-upgrade). Add "CI" section (workflow location, Gitea/GitHub setup, local gate command).

  • Step 3: Run local gate
cd backend && go vet ./... && gofmt -l . && \
DATABASE_URL=postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable \
REDIS_URL=redis://localhost:6379/0 \
go test -p 1 -count=1 ./...

Confirm 0 skip. Then run scripts/smoke.sh.

  • Step 4: Commit
git add docs/
git commit -m "docs: changelog + README for batch A (migrations, CI, B14/B15/B17)"

Part B — Bug Fixes B1–B13

Each bug: write failing test → fix → verify → continue. Tests use real PG/Redis (integration) until Part C converts applicable ones to fake-based unit tests.

Task 7: B1 — Rate limiter permanent lockout

Files:

  • Modify: backend/internal/redispkg/redis.go (IncrWindow)

  • Modify: backend/internal/redispkg/redis_test.go

  • Step 1: Add test

func TestIncrWindowSetsTTL(t *testing.T) {
	url := os.Getenv("REDIS_URL")
	if url == "" { t.Skip("REDIS_URL not set") }
	r := New(url)
	ctx := context.Background()
	key := "test:ttl:" + t.Name()
	r.c.Del(ctx, key)
	n := r.IncrWindow(ctx, key, 5*time.Second)
	if n != 1 { t.Fatalf("first = %d", n) }
	ttl, err := r.c.TTL(ctx, key).Result()
	if err != nil { t.Fatal(err) }
	if ttl <= 0 { t.Fatalf("TTL must be positive, got %v", ttl) }
}
  • Step 2: Fix IncrWindow with Lua script
var incrWindowScript = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
return n`)

func (r *R) IncrWindow(ctx context.Context, key string, ttl time.Duration) int {
	if r.c == nil { return 1 }
	n, err := incrWindowScript.Run(ctx, r.c, []string{key}, int(ttl.Seconds())).Int()
	if err != nil { return 1 }
	return n
}
  • Step 3: Run + commit
REDIS_URL=redis://localhost:6379/0 go test -v ./internal/redispkg/
git add backend/internal/redispkg/ && git commit -m "fix(redis): atomic INCR+EXPIRE via Lua (B1)"

Task 8: B2+B3 — Lock rand error + unlock context

Files:

  • Modify: backend/internal/redispkg/redis.go (Lock method)

  • Step 1: Fix token generation (B2)

In Lock, replace rand.Read(b) / tok := hex.EncodeToString(b) with:

	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		log.Printf("rand.Read: %v (degrading to no-lock)", err)
		return noop, true
	}
	tok := hex.EncodeToString(b)
  • Step 2: Fix unlock context (B3)

Replace the returned unlock closure's Eval call:

	return func() {
		if err := r.c.Eval(context.WithoutCancel(ctx),
			"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
			[]string{key}, tok).Err(); err != nil {
			log.Printf("unlock %s: %v", key, err)
		}
	}, true
  • Step 3: Run + commit
go test -v ./internal/redispkg/
git add backend/internal/redispkg/ && git commit -m "fix(redis): handle rand error + unlock survives ctx cancel (B2, B3)"

Task 9: B4 — Truncated part reported as received

Files:

  • Modify: backend/cmd/webui/handlers/uploads.go (UploadPart method)

  • Step 1: Fix part write to use tmp+rename

In UploadPart, replace the file-writing block (from p := filepath.Join(dir, "parts", ...) through the error handling):

	p := filepath.Join(dir, "parts", strconv.FormatInt(idx, 10))
	tmp := p + ".tmp"
	f, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
	if e != nil {
		err(c, http.StatusInternalServerError, "internal", "create part")
		return
	}
	n, e := io.Copy(f, c.Request.Body)
	f.Close()
	if e != nil || n != hi-lo {
		os.Remove(tmp)
		var mbe *http.MaxBytesError
		code, msg := "too_large", "part size mismatch"
		if errors.As(e, &mbe) { msg = "part exceeds declared size" }
		err(c, http.StatusRequestEntityTooLarge, code, msg)
		return
	}
	if e := os.Rename(tmp, p); e != nil {
		os.Remove(tmp)
		err(c, http.StatusInternalServerError, "internal", "rename part")
		return
	}
	c.JSON(http.StatusAccepted, gin.H{"accepted": true})
  • Step 2: Run + commit
go test -v ./cmd/webui/handlers/ -run Upload
git add backend/cmd/webui/handlers/uploads.go && git commit -m "fix(upload): write part to .tmp then rename (B4)"

Task 10: B5 — DeleteUser last-admin TOCTOU

Files:

  • Modify: backend/internal/store/store.go

  • Modify: backend/cmd/webui/handlers/users.go

  • Step 1: Add test to store_test.go

func TestDeleteUserLastAdmin(t *testing.T) {
	s := setup(t)
	ctx := context.Background()
	id, _ := s.CreateUser(ctx, "onlyadmin", "h", "admin")
	err := s.DeleteUser(ctx, id)
	if !errors.Is(err, ErrLastAdmin) {
		t.Fatalf("want ErrLastAdmin, got %v", err)
	}
	if _, err := s.GetUserByID(ctx, id); err != nil {
		t.Fatal("admin should still exist")
	}
}

func TestDeleteUserNonLastAdmin(t *testing.T) {
	s := setup(t)
	ctx := context.Background()
	id1, _ := s.CreateUser(ctx, "a1", "h", "admin")
	s.CreateUser(ctx, "a2", "h", "admin")
	if err := s.DeleteUser(ctx, id1); err != nil {
		t.Fatalf("non-last admin delete: %v", err)
	}
}
  • Step 2: Run to verify failure

Expected: ErrLastAdmin undefined.

  • Step 3: Add ErrLastAdmin + transactional DeleteUser to store.go

Add sentinel:

var ErrLastAdmin = errors.New("cannot delete the last admin")

Replace DeleteUser:

func (s *Store) DeleteUser(ctx context.Context, id int64) error {
	tx, err := s.P.Begin(ctx)
	if err != nil { return err }
	defer tx.Rollback(ctx)
	var role string
	if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil {
		return err
	}
	if role == "admin" {
		var n int
		if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil {
			return err
		}
		if n <= 1 { return ErrLastAdmin }
	}
	if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil { return err }
	return tx.Commit(ctx)
}

Remove the old exported CountAdmins method.

  • Step 4: Update users.go handler

Replace DeleteUser handler:

func (h *H) DeleteUser(c *gin.Context) {
	id, e := strconv.ParseInt(c.Param("id"), 10, 64)
	if e != nil { err(c, 400, "bad_request", "bad id"); return }
	if id == uid(c) { err(c, 400, "bad_request", "cannot delete yourself"); return }
	if e := h.st.DeleteUser(c, id); e != nil {
		if errors.Is(e, pgx.ErrNoRows) { err(c, 404, "not_found", "no such user"); return }
		if errors.Is(e, store.ErrLastAdmin) { err(c, 400, "bad_request", "cannot delete the last admin"); return }
		dbErr(c, e); return
	}
	c.Status(204)
}

Remove the old CountAdmins + GetUserByID + role check block. Add "booklib/internal/store" import.

  • Step 5: Run + commit
DATABASE_URL=... go test -v ./internal/store/ ./cmd/webui/handlers/
git add backend/internal/store/store.go backend/cmd/webui/handlers/users.go
git commit -m "fix(store): transactional last-admin check in DeleteUser (B5)"

Task 11: B6+B7 — Error semantics fixes

Files:

  • Modify: backend/cmd/webui/handlers/libraries.go (Upload method, B6)

  • Modify: backend/cmd/webui/handlers/auth.go (Me method, B7)

  • Step 1: Fix B6 — Upload io.Copy error mapping

In libraries.go Upload method, replace the io.Copy error block:

	if _, e := io.Copy(out, src); e != nil {
		out.Close()
		os.Remove(tmp)
		var mbe *http.MaxBytesError
		if errors.As(e, &mbe) {
			err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit")
			return
		}
		err(c, http.StatusInternalServerError, "internal", "upload failed")
		return
	}
  • Step 2: Fix B7 — Me handler error mapping

In auth.go Me method, replace:

func (h *H) Me(c *gin.Context) {
	u, qerr := h.st.GetUserByID(c, uid(c))
	if qerr != nil {
		if errors.Is(qerr, pgx.ErrNoRows) {
			err(c, http.StatusUnauthorized, "unauthorized", "no such user")
			return
		}
		dbErr(c, qerr)
		return
	}
	c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role})
}
  • Step 3: Run + commit
go test -v ./cmd/webui/handlers/
git add backend/cmd/webui/handlers/libraries.go backend/cmd/webui/handlers/auth.go
git commit -m "fix(handlers): correct error mapping for Upload (B6) and Me (B7)"

Task 12: B8 — Reserved library names

Files:

  • Create: backend/internal/media/reserved.go

  • Create: backend/internal/media/reserved_test.go

  • Modify: backend/cmd/webui/handlers/libraries.go

  • Step 1: Create reserved.go

package media

import "strings"

var reservedNames = map[string]bool{
	"cache": true, ".uploads": true, ".trash": true,
}

func IsReservedName(name string) bool {
	return reservedNames[strings.ToLower(name)]
}
  • Step 2: Create reserved_test.go
package media

import "testing"

func TestIsReservedName(t *testing.T) {
	for _, n := range []string{"cache", "Cache", "CACHE", ".uploads", ".Uploads", ".trash"} {
		if !IsReservedName(n) { t.Errorf("%q should be reserved", n) }
	}
	for _, n := range []string{"comics", "books", "my-library"} {
		if IsReservedName(n) { t.Errorf("%q should not be reserved", n) }
	}
}
  • Step 3: Add check in CreateLibrary handler

In libraries.go CreateLibrary, after SafeName check, add:

	if media.IsReservedName(safe) {
		err(c, http.StatusBadRequest, "bad_request", "reserved_name")
		return
	}

Import "booklib/internal/media".

  • Step 4: Run + commit
go test -v ./internal/media/ ./cmd/webui/handlers/ -run "Reserved\|CreateLibrary"
git add backend/internal/media/ backend/cmd/webui/handlers/libraries.go
git commit -m "fix(library): reject reserved names with 400 reserved_name (B8)"

Task 13: B9-① — Scan lock renewal

Files:

  • Modify: backend/internal/redispkg/redis.go

  • Modify: backend/internal/redispkg/redis_test.go

  • Modify: backend/internal/scanner/scanner.go

  • Step 1: Add test

func TestScanLockRenewal(t *testing.T) {
	url := os.Getenv("REDIS_URL")
	if url == "" { t.Skip("REDIS_URL not set") }
	r := New(url)
	ctx, key := context.Background(), "test:scanlock:"+t.Name()
	unlock, ok := r.ScanLock(ctx, key, 2*time.Second)
	if !ok { t.Fatal("should acquire") }
	time.Sleep(3 * time.Second) // would expire without renewal
	_, ok2 := r.ScanLock(ctx, key, 2*time.Second)
	if ok2 { t.Fatal("should still be held") }
	unlock()
}
  • Step 2: Add ScanLock to redis.go
func (r *R) ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool) {
	noop := func() {}
	if r.c == nil { return noop, true }
	b := make([]byte, 8)
	if _, err := rand.Read(b); err != nil {
		log.Printf("rand: %v", err); return noop, true
	}
	tok := hex.EncodeToString(b)
	ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
	if err != nil {
		log.Printf("scanlock %s: %v (proceeding)", key, err)
		return noop, true
	}
	if !ok { return noop, false }
	done := make(chan struct{})
	go func() {
		tk := time.NewTicker(ttl / 2); defer tk.Stop()
		for {
			select {
			case <-done: return
			case <-tk.C:
				r.c.Eval(context.Background(),
					`if redis.call('get',KEYS[1])==ARGV[1] then
						return redis.call('expire',KEYS[1],ARGV[2]) end`,
					[]string{key}, tok, int(ttl.Seconds()))
			}
		}
	}()
	return func() {
		close(done)
		r.c.Eval(context.WithoutCancel(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 3: Update scanner to use ScanLock

In scanner.go ScanLibrary, change:

	unlock, ok := s.rdb.Lock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)

to:

	unlock, ok := s.rdb.ScanLock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)
  • Step 4: Run + commit
REDIS_URL=redis://localhost:6379/0 go test -v -timeout 10s ./internal/redispkg/
go test -v ./internal/scanner/
git add backend/internal/redispkg/ backend/internal/scanner/scanner.go
git commit -m "fix(redis): ScanLock with auto-renewal for scanner (B9-①)"

Task 14: B10+B11 — Silent error logging fixes

Files:

  • Modify: backend/internal/scanner/scanner.go (cover, add, update)

  • Modify: backend/cmd/webui/handlers/content.go (ServeCover)

  • Step 1: Fix scanner add/update SetBookState (B10)

In both add and update, change:

	s.st.SetBookState(ctx, id, "error", idxErr.Error())

to:

	if e := s.st.SetBookState(ctx, id, "error", idxErr.Error()); e != nil {
		log.Printf("scan: SetBookState %s: %v", rel, e)
	}
  • Step 2: Fix scanner cover write (B11)

Replace the cover write section:

	tmp := filepath.Join(dir, "cover"+ext+".tmp")
	dst := filepath.Join(dir, "cover"+ext)
	if e := os.WriteFile(tmp, img, 0o644); e != nil {
		log.Printf("scan: cover tmp %s: %v", rel, e)
		os.Remove(tmp)
		return
	}
	if e := os.Rename(tmp, dst); e != nil {
		log.Printf("scan: cover rename %s: %v", rel, e)
		os.Remove(tmp)
		return
	}
  • Step 3: Fix ServeCover self-heal (B11)

In content.go, replace the self-heal write block:

	tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
	if e := os.WriteFile(tmp, img, 0o644); e != nil {
		log.Printf("serve: cover write: %v", e)
		os.Remove(tmp)
	} else if e := os.Rename(tmp, dst); e != nil {
		log.Printf("serve: cover rename: %v", e)
		os.Remove(tmp)
	} else {
		http.ServeFile(c.Writer, c.Request, dst)
	}

Add "log" import if missing.

  • Step 4: Run + commit
go test -v ./internal/scanner/ ./cmd/webui/handlers/
git add backend/internal/scanner/scanner.go backend/cmd/webui/handlers/content.go
git commit -m "fix: check cover write errors + log SetBookState failures (B10, B11)"

Task 15: B12 — uniquePath race

Files:

  • Modify: backend/cmd/webui/handlers/libraries.go (Upload method)

  • Step 1: Add retry loop in Upload handler

In Upload, replace the single uniquePath + OpenFile block with a retry:

	var dst string
	var out *os.File
	for attempt := 0; attempt < 5; attempt++ {
		var e error
		dst, e = h.uniquePath(root, name)
		if e != nil { err(c, http.StatusForbidden, "forbidden", e.Error()); return }
		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 { break }
		if !os.IsExist(e) { err(c, 500, "internal", "create tmp"); return }
	}
	if out == nil {
		err(c, http.StatusConflict, "conflict", "concurrent upload conflict")
		return
	}
  • Step 2: Run + commit
go test -v ./cmd/webui/handlers/ -run Upload
git add backend/cmd/webui/handlers/libraries.go
git commit -m "fix(upload): retry on O_EXCL collision (B12)"

Task 16: B13 — RowsAffected before err

Files:

  • Modify: backend/internal/store/store.go (UpdateBookmarkNote, DeleteBookmark)

  • Step 1: Fix both methods

func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
	res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
	if err != nil { return false, err }
	return res.RowsAffected() > 0, nil
}

func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
	res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
	if err != nil { return false, err }
	return res.RowsAffected() > 0, nil
}
  • Step 2: Run + commit
go test -v ./internal/store/
git add backend/internal/store/store.go
git commit -m "fix(store): check err before RowsAffected (B13)"

Task 17: B9-② — Scanner per-library single-flight

Files:

  • Modify: backend/internal/scanner/scanner.go

  • Step 1: Add sync.Map + scanOnce wrapper

Add field to Scanner:

	flights sync.Map // map[int64]*sync.WaitGroup

Add method:

func (s *Scanner) scanOnce(ctx context.Context, lib store.Library) {
	wg := &sync.WaitGroup{}
	wg.Add(1)
	if existing, loaded := s.flights.LoadOrStore(lib.ID, wg); loaded {
		existing.(*sync.WaitGroup).Wait()
		return
	}
	defer func() { s.flights.Delete(lib.ID); wg.Done() }()
	s.ScanLibrary(ctx, lib)
}

Update Run to call s.scanOnce(ctx, l) and ScanLibraryByID to call s.scanOnce(ctx, lib).

  • Step 2: Run + commit
go test -v ./internal/scanner/
git add backend/internal/scanner/scanner.go
git commit -m "fix(scanner): per-library single-flight (B9-②)"

Task 18: Batch B docs + local gate

Files:

  • Modify: docs/CHANGELOG.md

  • Step 1: Prepend CHANGELOG entries for B1–B13

Add entries for each fix in the [Unreleased] section. Format per global constraints (English+Chinese, blank between entries). See spec §S2 for the full list.

  • Step 2: Run full local gate
cd backend && go vet ./... && gofmt -l .
DATABASE_URL=postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable \
REDIS_URL=redis://localhost:6379/0 \
go test -p 1 -count=1 ./...
scripts/smoke.sh

Confirm 0 skip.

  • Step 3: Commit
git add docs/CHANGELOG.md
git commit -m "docs: changelog for batch B (B1-B13)"

Part C — Structure Restructure + Interfaces

Task 19: Define ports package

Files:

  • Create: backend/internal/ports/ports.go

  • Create: backend/internal/ports/errors.go

  • Step 1: Create ports.go

Define consumer-side interfaces. Value types use store.* (ports imports store for types only — one-way dependency):

package ports

import (
	"context"
	"time"
	"booklib/internal/store"
)

type UserStore interface {
	CountUsers(ctx context.Context) (int, error)
	CreateUser(ctx context.Context, username, hash, role string) (int64, error)
	GetUserByName(ctx context.Context, username string) (store.User, error)
	GetUserByID(ctx context.Context, id int64) (store.User, error)
	ListUsers(ctx context.Context) ([]store.User, error)
	DeleteUser(ctx context.Context, id int64) error
}

type LibraryStore interface {
	CreateLibrary(ctx context.Context, name, root string) (int64, error)
	ListLibraries(ctx context.Context) ([]store.Library, error)
	GetLibrary(ctx context.Context, id int64) (store.Library, error)
}

type BookStore interface {
	InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error)
	GetBook(ctx context.Context, id int64) (store.Book, error)
	ListBookMeta(ctx context.Context, libID int64) (map[string]store.BookMeta, error)
	UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error
	DeleteBookByPath(ctx context.Context, libID int64, path string) error
	DeleteBook(ctx context.Context, id int64) error
	SetBookState(ctx context.Context, id int64, state, msg string) error
	ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error)
	BookHashes(ctx context.Context) (map[int64][2]int64, error)
}

type ProgressStore interface {
	UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error
	GetProgress(ctx context.Context, userID, libID int64, bookPath string) (store.Progress, error)
	ListProgress(ctx context.Context, userID int64) ([]store.Progress, error)
}

type BookmarkStore interface {
	InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error)
	ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error)
	UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error)
	DeleteBookmark(ctx context.Context, userID, id int64) (bool, error)
}

type PageCache interface {
	Get(ctx context.Context, key string) (string, bool)
	Set(ctx context.Context, key, val string, ttl time.Duration)
}

type RateLimiter interface {
	IncrWindow(ctx context.Context, key string, ttl time.Duration) int
}

type ScanLocker interface {
	ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool)
}

type Scanner interface {
	ScanLibraryByID(ctx context.Context, id int64)
}

Note: UploadSessions and Media interfaces will be defined after internal/upload and internal/media are created (Tasks 22-23).

  • Step 2: Create errors.go
package ports

import "booklib/internal/store"

// Re-export sentinel errors so handlers use errors.Is via ports.
var ErrLastAdmin = store.ErrLastAdmin

// IsUniqueViolation consolidates the 3 copies of pg 23505 check.
func IsUniqueViolation(err error) bool { return store.IsUniqueViolation(err) }
  • Step 3: Add IsUniqueViolation + ErrUniqueViolation to store

In store.go, add:

var ErrUniqueViolation = errors.New("unique violation")

func IsUniqueViolation(err error) bool {
	var pgErr *pgconn.PgError
	return errors.As(err, &pgErr) && pgErr.Code == "23505"
}

Update ports/errors.go to also re-export:

var ErrUniqueViolation = store.ErrUniqueViolation
  • Step 4: Verify build
cd backend && go build ./...
  • Step 5: Commit
git add backend/internal/ports/ backend/internal/store/store.go
git commit -m "feat(ports): define consumer-side interfaces and shared errors"

Task 20: Split store package

Files:

  • Create: backend/internal/store/users.go

  • Create: backend/internal/store/libraries.go

  • Create: backend/internal/store/books.go

  • Create: backend/internal/store/progress.go

  • Create: backend/internal/store/bookmarks.go

  • Modify: backend/internal/store/store.go (keep types + ctor + shared helpers only)

  • Modify: backend/internal/store/store_test.go (extract per-file tests)

  • Step 1: Extract methods by aggregate

Move methods from store.go into per-aggregate files:

  • users.go: CountUsers, CreateUser, GetUserByName, GetUserByID, ListUsers, DeleteUser, scanUser, CountAdmins (make unexported: countAdmins)
  • libraries.go: CreateLibrary, ListLibraries, GetLibrary
  • books.go: InsertBook, GetBook, ListBookMeta, UpdateBookFile, DeleteBookByPath, DeleteBook, SetBookState, ListBooks, BookHashes. Delete ListBookIDs (dead code per spec).
  • progress.go: UpsertProgress, GetProgress, ListProgress
  • bookmarks.go: InsertBookmark, ListBookmarks, UpdateBookmarkNote, DeleteBookmark

Each file: package store, receives methods on *Store.

  • Step 2: Slim down store.go

Keep: type definitions (Store, User, Library, Book, BookMeta, BookView, Progress, Bookmark), New(), IsUniqueViolation(), ErrLastAdmin, column constants (userCols, bookCols).

Make P field unexported (p *pgxpool.Pool). Update all method files to use s.p instead of s.P. Update store_test.go setup if it uses s.P.

  • Step 3: Verify build + tests
cd backend && go build ./... && DATABASE_URL=... go test ./internal/store/
  • Step 4: Commit
git add backend/internal/store/
git commit -m "refactor(store): split into per-aggregate files, unexport pool"

Task 21: Add bookfile utilities

Files:

  • Modify: backend/internal/bookfile/bookfile.go (add Contains)

  • Create: backend/internal/bookfile/reader.go (OpenReaderAt)

  • Step 1: Add Contains function

// Contains reports whether child is inside parent (EvalSymlinks semantics).
func Contains(parent, child string) bool {
	p, err := filepath.EvalSymlinks(parent)
	if err != nil { p = filepath.Clean(parent) }
	c, err := filepath.EvalSymlinks(child)
	if err != nil { c = filepath.Clean(child) }
	return c == p || strings.HasPrefix(c, p+string(os.PathSeparator))
}
  • Step 2: Add OpenReaderAt

Consolidate the 3 places that open a book file + stat + get ReaderAt:

// OpenReaderAt opens a book file and returns a ReaderAt + size.
func OpenReaderAt(root, rel string) (*os.File, int64, error) {
	abs := filepath.Join(root, filepath.FromSlash(rel))
	f, err := os.Open(abs)
	if err != nil { return nil, 0, err }
	st, err := f.Stat()
	if err != nil { f.Close(); return nil, 0, err }
	return f, st.Size(), nil
}
  • Step 3: Add tests
func TestContains(t *testing.T) {
	dir := t.TempDir()
	sub := filepath.Join(dir, "sub")
	os.MkdirAll(sub, 0o755)
	if !Contains(dir, sub) { t.Fatal("sub should be inside dir") }
	if Contains(sub, dir) { t.Fatal("dir should not be inside sub") }
}
  • Step 4: Run + commit
go test -v ./internal/bookfile/
git add backend/internal/bookfile/
git commit -m "feat(bookfile): add Contains and OpenReaderAt utilities"

Task 22: Create internal/media package

Files:

  • Modify: backend/internal/media/reserved.go (already exists from Task 12)

  • Create: backend/internal/media/media.go (Media struct + interface methods)

  • Create: backend/internal/media/write.go (WriteAtomic)

  • Create: backend/internal/media/chapters.go (ChaptersOf)

  • Create: backend/internal/media/ports.go (Media interface definition)

  • Step 1: Create WriteAtomic

package media

import (
	"log"
	"os"
	"path/filepath"
	"fmt"
	"time"
)

// WriteAtomic writes data to dst via tmp+rename. Cleans tmp only on failure.
func WriteAtomic(dir, name string, data []byte) error {
	if err := os.MkdirAll(dir, 0o755); err != nil { return err }
	tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		os.Remove(tmp)
		return err
	}
	dst := filepath.Join(dir, name)
	if err := os.Rename(tmp, dst); err != nil {
		os.Remove(tmp)
		return err
	}
	return nil
}
  • Step 2: Move ChaptersOf from content.go

Copy chaptersOf and cbzChapter from handlers/content.go into media/chapters.go (exported as ChaptersOf). The handler will call media.ChaptersOf(idx).

  • Step 3: Move cache layout functions

Move DirKey, CoverDir, PagesDir, SweepStale, Hash from bookfile/cache.go and bookfile/hash.go into media/. Or keep them in bookfile and have media wrap them — whichever avoids a large move. Per spec, media is the "single source of truth" for cache layout. Move them to media/cache.go:

package media

import "booklib/internal/bookfile"

// Re-export cache layout from bookfile for backward compat.
// Future: move implementations here.
var DirKey = bookfile.DirKey
var CoverDir = bookfile.CoverDir
var PagesDir = bookfile.PagesDir
var SweepStale = bookfile.SweepStale
var Hash = bookfile.Hash

Actually simpler: just have Media methods call bookfile functions directly. No re-export needed. Media is the consumer; bookfile stays as the low-level utility package.

  • Step 4: Create Media struct
package media

import (
	"context"
	"booklib/internal/bookfile"
	"booklib/internal/config"
	"booklib/internal/redispkg"
)

type M struct {
	cfg *config.Config
	rdb *redispkg.R
}

func New(cfg *config.Config, rdb *redispkg.R) *M {
	return &M{cfg: cfg, rdb: rdb}
}
  • Step 5: Implement Media methods

Implement on *M:

  • EnsureCover(ctx, bookID, format, size, modTS, root, rel) — extracted from scanner.cover

  • EnsurePage(ctx, bookID, size, modTS, root, rel, n) — extracted from content.go Page handler

  • ChaptersOf(idx) — from step 2

  • PageIndex(ctx, bookID, size, modTS, root, rel) — extracted from content.go pageIndex

  • CacheBuster(size, modTS) — returns bookfile.Hash(size, modTS)

  • Step 6: Create Media interface in ports.go

Add to ports/ports.go (or ports/media.go):

type Media interface {
	EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error
	EnsurePage(ctx context.Context, bookID int64, size, modTS int64, root, rel string, n int) (string, error)
	ChaptersOf(idx []string) []Chapter
	PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
	CacheBuster(size, modTS int64) string
}
  • Step 7: Build + commit
go build ./...
git add backend/internal/media/ backend/internal/ports/
git commit -m "feat(media): cache layout single source of truth + WriteAtomic + chapters"

Task 23: Create internal/upload package + B16

Files:

  • Create: backend/internal/upload/upload.go

  • Create: backend/internal/upload/upload_test.go

  • Create: backend/internal/upload/ports.go (UploadSessions interface)

  • Modify: backend/internal/scanner/scanner.go (add Sweep to ticker, B16)

  • Step 1: Move upload domain logic

Extract from handlers/uploads.go into internal/upload/upload.go:

  • uploadIDFor, uploadDir, chunkRange, numParts, uploadMeta, validUploadID
  • Init(libID, name, size, chunkSize) → returns uploadID
  • Status(uploadID) → returns received part indices
  • PutPart(uploadID, index, body) → writes part with tmp+rename (B4 fix already applied)
  • Complete(uploadID) → assembles + renames to library root (uses uniquePath)
  • Sweep() → deletes expired sessions (B16: moved from request path to scanner ticker)
  • UniquePath(root, name) → shared between single-file and chunked upload

The upload package takes booksDir string and uploadMaxMB int64 in its constructor.

  • Step 2: Define UploadSessions interface in ports

Add to ports/ports.go:

type UploadSessions interface {
	Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error)
	Status(ctx context.Context, uploadID string) ([]int64, error)
	PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error
	Complete(ctx context.Context, uploadID string, root string) (string, error)
	Sweep(ctx context.Context) error
	UniquePath(root, name string) (string, error)
}

Add to ports/errors.go:

var (
	ErrTooLarge     = errors.New("file too large")
	ErrIncomplete   = errors.New("upload incomplete")
	ErrSizeMismatch = errors.New("size mismatch")
	ErrNotFound     = errors.New("not found")
)
  • Step 3: Wire Sweep into scanner ticker (B16)

In scanner.go Run method, add sweep call each tick:

case <-t.C:
	h.sweepUploads() // B16: moved from request path
	libs, err := s.st.ListLibraries(ctx)
	...

The scanner needs access to the upload package. Add up *upload.U field to Scanner. Or have main.go pass the sweep function. Simplest: scanner gets an upload.Sweeper interface:

type Sweeper interface { Sweep(ctx context.Context) error }
  • Step 4: Build + commit
go build ./...
git add backend/internal/upload/ backend/internal/scanner/ backend/internal/ports/
git commit -m "feat(upload): extract upload subsystem + move sweep to scanner ticker (B16)"

Task 24: Refactor scanner

Files:

  • Modify: backend/internal/scanner/scanner.go

  • Step 1: Merge add/update into ingest

Consolidate the duplicated logic in add and update:

func (s *Scanner) ingest(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat, isNew bool) {
	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 idxErr == nil && pageCount == 0 {
			idxErr = errors.New("no images in archive")
		}
	}
	if isNew {
		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 }
		bookID = id
	} else {
		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 {
		if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil {
			log.Printf("scan: SetBookState %s: %v", rel, e)
		}
		return
	}
	s.cover(ctx, bookID, root, rel, format, ds)
}
  • Step 2: Use bookfile.OpenReaderAt

Replace readCover and zipIndex to use bookfile.OpenReaderAt:

func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
	f, size, err := bookfile.OpenReaderAt(root, rel)
	if err != nil { return nil, err }
	defer f.Close()
	return bookfile.PageIndex(f, size)
}
  • Step 3: Use bookfile.Contains

Replace the inside function call:

if !bookfile.Contains(s.cfg.BooksDir, root) { ... }

Remove the local inside function.

  • Step 4: Use media.WriteAtomic in cover

Replace manual tmp+rename in cover with media.WriteAtomic(dir, "cover"+ext, img).

  • Step 5: Run + commit
go test -v ./internal/scanner/
git add backend/internal/scanner/
git commit -m "refactor(scanner): merge add/update into ingest, use shared utilities"

Task 25: Slim down handlers

Files:

  • Modify: All backend/cmd/webui/handlers/*.go

  • Modify: backend/cmd/webui/handlers/handlers.go

  • Step 1: Change H struct to use port interfaces

type H struct {
	cfg      *config.Config
	users    ports.UserStore
	libs     ports.LibraryStore
	books    ports.BookStore
	progress ports.ProgressStore
	bookmarks ports.BookmarkStore
	cache    ports.PageCache
	rl       ports.RateLimiter
	scanner  ports.Scanner
	media    media.M  // or ports.Media once interface finalized
	upload   upload.U // or ports.UploadSessions
}

Update New() to accept all interfaces.

  • Step 2: Update all handler methods

Replace h.st.Xxx(...) with the appropriate port field. Deduplicate:

  • getLibrary and getLibRow → single getLib(c, id) helper

  • strconv.ParseInt(c.Param("id"), ...) → single idParam(c) helper

  • isUnique → ports.IsUniqueViolation

  • Path contains checks → bookfile.Contains

  • Step 3: Update router.go to pass new H

Update api/router.go and handlers.New() signature to accept all dependencies.

  • Step 4: Build + commit
go build ./...
git add backend/cmd/webui/
git commit -m "refactor(handlers): use port interfaces, deduplicate helpers"

Task 26: Wire main.go assembly

Files:

  • Modify: backend/cmd/webui/main.go

  • Step 1: Update main.go to assemble all dependencies

func main() {
	cfg, err := config.Load()
	if err != nil { log.Fatalf("config: %v", err) }
	// ... ctx, db, migrate as before ...
	st := store.New(p)
	seed.Admin(ctx, st, cfg.AdminUser, cfg.AdminPassword)
	rdb := redispkg.New(cfg.RedisURL)
	med := media.New(cfg, rdb)
	up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
	sc := scanner.New(st, cfg, rdb, med, up)

	// Wire router with port interfaces.
	// *store.Store satisfies UserStore, LibraryStore, etc.
	// *redispkg.R satisfies PageCache, RateLimiter, ScanLocker.
	r := api.NewRouter(cfg, st, st, st, st, st, rdb, rdb, sc, med, up)
	// ... serve as Task 3 pattern ...
}
  • Step 2: Build + smoke
cd backend && go build ./cmd/webui && scripts/smoke.sh
  • Step 3: Commit
git add backend/cmd/webui/main.go
git commit -m "refactor(main): assemble dependencies via port interfaces"

Task 27: Create portsfake + contract tests + handler unit tests

Files:

  • Create: backend/internal/ports/portsfake/fake.go

  • Create: backend/cmd/webui/handlers/users_unit_test.go (and similar per handler)

  • Modify: backend/cmd/webui/api/router_test.go (contract test)

  • Step 1: Create hand-written fakes

portsfake/fake.go implements all port interfaces with in-memory maps:

package portsfake

type Users struct {
	m    map[int64]store.User
	next int64
}

func NewUsers() *Users { return &Users{m: map[int64]store.User{}, next: 1} }

func (u *Users) CreateUser(_ context.Context, username, hash, role string) (int64, error) {
	for _, v := range u.m {
		if v.Username == username { return 0, ports.ErrUniqueViolation }
	}
	id := u.next; u.next++
	u.m[id] = store.User{ID: id, Username: username, PasswordHash: hash, Role: role}
	return id, nil
}
// ... implement all interface methods

Similarly for Libraries, Books, Progress, Bookmarks fakes. And a simple in-memory PageCache, RateLimiter, ScanLocker.

  • Step 2: Add router contract test

In router_test.go, pin the expected route table:

func TestRouterContract(t *testing.T) {
	r := NewRouter(/* fakes */)
	routes := r.Routes()
	expected := map[string]string{
		"GET /api/healthz": "",
		"POST /api/auth/login": "",
		"GET /api/auth/me": "auth",
		"GET /api/users": "auth,admin",
		// ... all routes with middleware markers
	}
	// Assert exact match — any route change requires updating this test.
}
  • Step 3: Add handler unit tests

Example users_unit_test.go:

func TestDeleteUserLastAdminReturns400(t *testing.T) {
	h := newTestH() // uses portsfake
	// Seed one admin
	h.users.CreateUser(ctx, "admin", "h", "admin")
	// Try to delete → should get 400
	w := httptest.NewRecorder()
	c, _ := gin.CreateTestContext(w)
	c.Params = gin.Params{{Key: "id", Value: "1"}}
	c.Set("uid", int64(999)) // different user
	h.DeleteUser(c)
	if w.Code != 400 { t.Fatalf("want 400, got %d", w.Code) }
}
  • Step 4: Run all tests (unit + integration)
go test -p 1 -count=1 ./...
  • Step 5: Commit
git add backend/internal/ports/portsfake/ backend/cmd/webui/handlers/*_unit_test.go backend/cmd/webui/api/router_test.go
git commit -m "test: add portsfake, router contract tests, handler unit tests"

Task 28: Consolidate isUnique + dedup helpers

Files:

  • Modify: backend/cmd/webui/handlers/users.go (remove local isUnique)

  • Modify: backend/internal/seed/seed.go (use ports.IsUniqueViolation)

  • Modify: All handlers that duplicate error-check patterns

  • Step 1: Replace all isUnique calls

Search for isUnique( and pgconn.PgError with code 23505 across the codebase. Replace with ports.IsUniqueViolation(err) or store.IsUniqueViolation(err).

Remove the local isUnique function from handlers/users.go. Update seed.go to use store.IsUniqueViolation.

  • Step 2: Consolidate path validation

Replace the 3 different path-contains checks with bookfile.Contains:

  • libraries.go libRoot → bookfile.Contains(h.cfg.BooksDir, root)
  • books.go hasPrefixDir → bookfile.Contains
  • scanner.go inside → bookfile.Contains (already done in Task 24)

Remove local helper functions.

  • Step 3: Run + commit
go build ./... && go test -p 1 -count=1 ./...
git add -A && git commit -m "refactor: consolidate isUnique and path validation helpers"

Task 29: Final docs + local gate

Files:

  • Modify: docs/CHANGELOG.md

  • Modify: docs/README.md, docs/README_zh.md

  • Step 1: Add CHANGELOG for Batch C

### Changed
- Business logic restructured: handlers now consume small port interfaces, domain logic in internal/media and internal/upload.
- 业务逻辑重构:handlers 消费小口径 port 接口,域逻辑下沉至 internal/media 和 internal/upload。
- Upload sweep moved from request path to scanner ticker cycle (B16).
- 上传会话清理从请求路径移至扫描器定时周期(B16)。

### Added
- Router contract test pins expected route table; unauthorized changes fail CI.
- 路由契约测试锁定预期路由表,未授权变更将导致 CI 失败。
- Hand-written in-memory fakes (portsfake) enable handler unit tests without PG/Redis.
- 手写内存 fake(portsfake)实现无 PG/Redis 的 handler 单测。
  • Step 2: Update READMEs

Document the new package structure (ports, media, upload). Explain the interface/fake testing approach.

  • Step 3: Full local gate
cd backend && go vet ./... && gofmt -l .
DATABASE_URL=postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable \
REDIS_URL=redis://localhost:6379/0 \
go test -p 1 -count=1 -v ./...
scripts/smoke.sh

Confirm:

  • 0 skip in test output

  • All 47+ original integration tests pass

  • New fake-based unit tests pass

  • Router contract test passes

  • smoke.sh passes

  • Step 4: Commit

git add docs/
git commit -m "docs: changelog + README for batch C (restructure, contract tests)"

Verification Checklist

After all 29 tasks:

  1. go vet ./... — clean
  2. gofmt -l . — empty output
  3. go test -p 1 -count=1 ./... — all pass, 0 skip
  4. scripts/smoke.sh — ALL SMOKE TESTS PASSED
  5. scripts/smoke-web.sh — passes (if compose available)
  6. Baseline test: old DB → new code → schema_migrations has 0001_baseline.sql row
  7. No CountAdmins exported (removed)
  8. No ListBookIDs exported (removed, dead code)
  9. No local isUnique functions outside store.IsUniqueViolation
  10. No inside function in scanner (replaced by bookfile.Contains)
  11. Router contract test pins all routes
  12. All 17 bugs (B1–B17) have corresponding tests or smoke verification