53 KiB
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.mdanddocs/README_zh.mdstay 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 ininternal/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, plusscripts/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.Poolfromdb.Connect -
Produces:
db.Migrate(ctx, pool) error— same signature, different internals;schema_migrationstable 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.gowith 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):
- Start app → verify
SELECT * FROM schema_migrationshas one row (version=1). - Verify existing data untouched (
SELECT count(*) FROM bookssame as before). - 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_pathfrom 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,GetLibrarybooks.go:InsertBook,GetBook,ListBookMeta,UpdateBookFile,DeleteBookByPath,DeleteBook,SetBookState,ListBooks,BookHashes. DeleteListBookIDs(dead code per spec).progress.go:UpsertProgress,GetProgress,ListProgressbookmarks.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)— returnsbookfile.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,validUploadIDInit(libID, name, size, chunkSize)→ returns uploadIDStatus(uploadID)→ returns received part indicesPutPart(uploadID, index, body)→ writes part with tmp+rename (B4 fix already applied)Complete(uploadID)→ assembles + renames to library root (usesuniquePath)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:
-
getLibraryandgetLibRow→ singlegetLib(c, id)helper -
strconv.ParseInt(c.Param("id"), ...)→ singleidParam(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 localisUnique) -
Modify:
backend/internal/seed/seed.go(useports.IsUniqueViolation) -
Modify: All handlers that duplicate error-check patterns
-
Step 1: Replace all
isUniquecalls
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.golibRoot→bookfile.Contains(h.cfg.BooksDir, root)books.gohasPrefixDir→bookfile.Containsscanner.goinside→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:
go vet ./...— cleangofmt -l .— empty outputgo test -p 1 -count=1 ./...— all pass, 0 skipscripts/smoke.sh— ALL SMOKE TESTS PASSEDscripts/smoke-web.sh— passes (if compose available)- Baseline test: old DB → new code →
schema_migrationshas0001_baseline.sqlrow - No
CountAdminsexported (removed) - No
ListBookIDsexported (removed, dead code) - No local
isUniquefunctions outsidestore.IsUniqueViolation - No
insidefunction in scanner (replaced bybookfile.Contains) - Router contract test pins all routes
- All 17 bugs (B1–B17) have corresponding tests or smoke verification