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

4398 lines
123 KiB
Markdown
Raw Blame History

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