docs: tick completed plan checkboxes, mark spec as implemented

This commit is contained in:
2026-09-07 17:27:24 +08:00
parent 73af5c1d78
commit 22ae1717f4
3 changed files with 138 additions and 138 deletions
+63 -63
View File
@@ -71,14 +71,14 @@ docker-compose.yml # web, api(xN), postgres, redis, testprofile
- Consumes: 无 - 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` - 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** - [x] **Step 1: 初始化 module**
```bash ```bash
cd backend && go mod init booklib 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 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`** - [x] **Step 2: 写失败测试 `internal/api/router_test.go`**
```go ```go
package api package api
@@ -107,12 +107,12 @@ func TestHealthz(t *testing.T) {
} }
``` ```
- [ ] **Step 3: 跑测试确认失败** - [x] **Step 3: 跑测试确认失败**
Run: `cd backend && go test ./internal/api/ -run TestHealthz -v` Run: `cd backend && go test ./internal/api/ -run TestHealthz -v`
Expected: 编译失败 "undefined: NewRouter" Expected: 编译失败 "undefined: NewRouter"
- [ ] **Step 4: 实现 config + router** - [x] **Step 4: 实现 config + router**
`internal/config/config.go`: `internal/config/config.go`:
@@ -202,12 +202,12 @@ func NewRouter(cfg *config.Config) *gin.Engine {
} }
``` ```
- [ ] **Step 5: 跑测试确认通过** - [x] **Step 5: 跑测试确认通过**
Run: `cd backend && go test ./internal/api/ -v` Run: `cd backend && go test ./internal/api/ -v`
Expected: PASS Expected: PASS
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git init -b main . && git add backend && git commit -m "feat(backend): module scaffold, config, gin router with healthz" git init -b main . && git add backend && git commit -m "feat(backend): module scaffold, config, gin router with healthz"
@@ -268,7 +268,7 @@ func db.Connect(ctx, url string) (*pgxpool.Pool, error)
func db.Migrate(ctx, p *pgxpool.Pool) error // 执行内嵌 schema.sql,幂等 func db.Migrate(ctx, p *pgxpool.Pool) error // 执行内嵌 schema.sql,幂等
``` ```
- [ ] **Step 1: 起测试用 PG/Redis** - [x] **Step 1: 起测试用 PG/Redis**
`deploy/docker-compose.dev.yml`: `deploy/docker-compose.dev.yml`:
@@ -287,7 +287,7 @@ services:
Run: `docker compose -f deploy/docker-compose.dev.yml up -d --wait` Run: `docker compose -f deploy/docker-compose.dev.yml up -d --wait`
Expected: 两个 healthy。后续所有集成测试用 `DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable`。 Expected: 两个 healthy。后续所有集成测试用 `DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable`。
- [ ] **Step 2: 写 schema.sql(内嵌,幂等)** - [x] **Step 2: 写 schema.sql(内嵌,幂等)**
`internal/db/schema.sql`: `internal/db/schema.sql`:
@@ -315,7 +315,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
PRIMARY KEY (user_id, library_id, book_path)); PRIMARY KEY (user_id, library_id, book_path));
``` ```
- [ ] **Step 3: 写失败测试 `store_test.go`(全部 SQL 行为在此锁死)** - [x] **Step 3: 写失败测试 `store_test.go`(全部 SQL 行为在此锁死)**
```go ```go
package store package store
@@ -438,12 +438,12 @@ func TestProgressUpsertAndJoin(t *testing.T) {
(BookMeta 只带 diff 需要的 ID/Size/ModTS/Format;page_count 走 GetBook,测试里已有覆盖。) (BookMeta 只带 diff 需要的 ID/Size/ModTS/Format;page_count 走 GetBook,测试里已有覆盖。)
- [ ] **Step 4: 跑测试确认失败** - [x] **Step 4: 跑测试确认失败**
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v` 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" Expected: 编译失败 "booklib/internal/db: no such package"
- [ ] **Step 5: 实现 db.go 与 store.go** - [x] **Step 5: 实现 db.go 与 store.go**
`internal/db/db.go`: `internal/db/db.go`:
@@ -790,12 +790,12 @@ func (s *Store) GetProgress(ctx context.Context, userID, libID int64, bookPath s
} }
``` ```
- [ ] **Step 6: 跑测试确认通过** - [x] **Step 6: 跑测试确认通过**
Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v` Run: `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable go test ./internal/store/ -v`
Expected: 2 个测试 PASS(无 DATABASE_URL 时 SKIP) Expected: 2 个测试 PASS(无 DATABASE_URL 时 SKIP)
- [ ] **Step 7: Commit** - [x] **Step 7: Commit**
```bash ```bash
git add backend deploy && git commit -m "feat(backend): pg schema + store queries incl. path-keyed progress" git add backend deploy && git commit -m "feat(backend): pg schema + store queries incl. path-keyed progress"
@@ -824,7 +824,7 @@ auth.Parse(secret []byte, token string) (*auth.Claims, error)
seed.Admin(ctx context.Context, s *store.Store, user, pass string) error // users 为空才建;user/pass 空则跳过;失败仅由调用方记日志 seed.Admin(ctx context.Context, s *store.Store, user, pass string) error // users 为空才建;user/pass 空则跳过;失败仅由调用方记日志
``` ```
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/auth/auth_test.go`: `internal/auth/auth_test.go`:
@@ -917,12 +917,12 @@ func TestSeedOnlyWhenEmpty(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 跑,确认编译失败** - [x] **Step 2: 跑,确认编译失败**
Run: `cd backend && go test ./internal/auth/ ./internal/seed/ -v` Run: `cd backend && go test ./internal/auth/ ./internal/seed/ -v`
Expected: FAIL — undefined Expected: FAIL — undefined
- [ ] **Step 3: 实现** - [x] **Step 3: 实现**
`internal/auth/auth.go`: `internal/auth/auth.go`:
@@ -1017,12 +1017,12 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error {
} }
``` ```
- [ ] **Step 4: 跑测试确认通过** - [x] **Step 4: 跑测试确认通过**
Run: `cd backend && go test ./internal/auth/ -v && DATABASE_URL=... go test ./internal/seed/ -v` Run: `cd backend && go test ./internal/auth/ -v && DATABASE_URL=... go test ./internal/seed/ -v`
Expected: PASS Expected: PASS
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add backend && git commit -m "feat(backend): bcrypt+jwt auth package and admin seeding" git add backend && git commit -m "feat(backend): bcrypt+jwt auth package and admin seeding"
@@ -1055,7 +1055,7 @@ api 中间件设置 ctx key: "uid" int64 / "role" string;helpers api.uid(c)/api.
NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine // scanner 参数 Task 9 再加 NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine // scanner 参数 Task 9 再加
``` ```
- [ ] **Step 1: 写失败测试 `auth_test.go`** - [x] **Step 1: 写失败测试 `auth_test.go`**
```go ```go
package api package api
@@ -1193,12 +1193,12 @@ func TestDisabledIsSafe(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/redispkg/ -v` Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/redispkg/ -v`
Expected: 编译失败 undefined: redispkg / NewRouter 签名不符 Expected: 编译失败 undefined: redispkg / NewRouter 签名不符
- [ ] **Step 3: 实现 redispkg** - [x] **Step 3: 实现 redispkg**
`internal/redispkg/redis.go`: `internal/redispkg/redis.go`:
@@ -1283,7 +1283,7 @@ func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bo
} }
``` ```
- [ ] **Step 4: 实现 api struct/错误/中间件 + auth handlers,重写 NewRouter** - [x] **Step 4: 实现 api struct/错误/中间件 + auth handlers,重写 NewRouter**
`internal/api/api.go`: `internal/api/api.go`:
@@ -1442,12 +1442,12 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R) *gin.Engine
} }
``` ```
- [ ] **Step 5: 跑测试确认通过** - [x] **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` 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 路径) Expected: PASS(REDIS_URL 不给也应 PASS — no-op 路径)
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git add backend && git commit -m "feat(backend): jwt middleware, login with redis rate limit, /auth/me" git add backend && git commit -m "feat(backend): jwt middleware, login with redis rate limit, /auth/me"
@@ -1470,7 +1470,7 @@ git add backend && git commit -m "feat(backend): jwt middleware, login with redi
`bookfile.FormatFromExt(name string) string` — `.cbz→"cbz" .pdf→"pdf" .epub→"epub" .txt→"txt" .md→"md"`,其他 `"..."` → `""` `bookfile.FormatFromExt(name string) string` — `.cbz→"cbz" .pdf→"pdf" .epub→"epub" .txt→"txt" .md→"md"`,其他 `"..."` → `""`
`bookfile.SafeName(s string) string` — 去目录、去控制符、trim、限长 200、保留空格中文 `bookfile.SafeName(s string) string` — 去目录、去控制符、trim、限长 200、保留空格中文
- [ ] **Step 1: 写失败测试(bookfile 先行,纯函数)** - [x] **Step 1: 写失败测试(bookfile 先行,纯函数)**
`internal/bookfile/bookfile_test.go`: `internal/bookfile/bookfile_test.go`:
@@ -1511,7 +1511,7 @@ func TestSafeName(t *testing.T) {
(`"tar.gz"→""`:扩展名是 `.gz` 不在白名单,正确。`"con\ntl.bin"` → base 后去 `\t` 得 `cntl.bin`。) (`"tar.gz"→""`:扩展名是 `.gz` 不在白名单,正确。`"con\ntl.bin"` → base 后去 `\t` 得 `cntl.bin`。)
- [ ] **Step 2: 跑确认失败;实现 `bookfile.go`** - [x] **Step 2: 跑确认失败;实现 `bookfile.go`**
```go ```go
package bookfile package bookfile
@@ -1560,7 +1560,7 @@ func SafeName(s string) string {
Run: `go test ./internal/bookfile/ -v` → PASS。 Run: `go test ./internal/bookfile/ -v` → PASS。
- [ ] **Step 3: 写失败测试 users/libraries(API)** - [x] **Step 3: 写失败测试 users/libraries(API)**
`internal/api/users_test.go`: `internal/api/users_test.go`:
@@ -1732,7 +1732,7 @@ func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writ
(`libraries_test.go` 需 import `bytes`;`httptest.NewRequest("POST", url, buf)` 直接收 `*bytes.Buffer`。) (`libraries_test.go` 需 import `bytes`;`httptest.NewRequest("POST", url, buf)` 直接收 `*bytes.Buffer`。)
- [ ] **Step 4: 跑确认失败;实现 users.go / libraries.go / router 接线** - [x] **Step 4: 跑确认失败;实现 users.go / libraries.go / router 接线**
先修 `auth_test.go` 的 setupAPI(upload 测试需要,libRoot 校验依赖 BooksDir;auth_test.go 补 import `path/filepath`): 先修 `auth_test.go` 的 setupAPI(upload 测试需要,libRoot 校验依赖 BooksDir;auth_test.go 补 import `path/filepath`):
@@ -2038,12 +2038,12 @@ func (a *api) uniquePath(root, name string) (string, error) {
libs.POST("/:id/upload", a.adminOnly(), a.upload) libs.POST("/:id/upload", a.adminOnly(), a.upload)
``` ```
- [ ] **Step 5: 跑测试确认通过** - [x] **Step 5: 跑测试确认通过**
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/bookfile/ -v` Run: `cd backend && DATABASE_URL=... go test ./internal/api/ ./internal/bookfile/ -v`
Expected: PASS Expected: PASS
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git add backend && git commit -m "feat(backend): user + library admin API, atomic sanitized upload" git add backend && git commit -m "feat(backend): user + library admin API, atomic sanitized upload"
@@ -2068,7 +2068,7 @@ bookfile.NaturalLess(a, b string) bool
bookfile.Hash(size, modTS int64) string // sha256("size:mtime") hex 前 16 字符 bookfile.Hash(size, modTS int64) string // sha256("size:mtime") hex 前 16 字符
``` ```
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/bookfile/zip_test.go`。测试内构造 zip 夹具(不落盘,bytes.Buffer + zip.Writer): `internal/bookfile/zip_test.go`。测试内构造 zip 夹具(不落盘,bytes.Buffer + zip.Writer):
@@ -2162,12 +2162,12 @@ func TestHash(t *testing.T) {
注意 `{"007","7"}` 与 `{"A","a"}` 两个用例:数字相等时按原始串比较(`"007" < "7"`,字典序),纯 ASCII 大小写按字节序。 注意 `{"007","7"}` 与 `{"A","a"}` 两个用例:数字相等时按原始串比较(`"007" < "7"`,字典序),纯 ASCII 大小写按字节序。
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd backend && go test ./internal/bookfile/ -run 'PageIndex|ReadEntry|Natural|Hash' -v` Run: `cd backend && go test ./internal/bookfile/ -run 'PageIndex|ReadEntry|Natural|Hash' -v`
Expected: 编译失败 undefined Expected: 编译失败 undefined
- [ ] **Step 3: 实现 `zip.go` 与 `hash.go`** - [x] **Step 3: 实现 `zip.go` 与 `hash.go`**
`internal/bookfile/zip.go`: `internal/bookfile/zip.go`:
@@ -2290,7 +2290,7 @@ func Hash(size, modTS int64) string {
} }
``` ```
- [ ] **Step 4: 跑确认通过;Commit** - [x] **Step 4: 跑确认通过;Commit**
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
@@ -2315,7 +2315,7 @@ 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 bookfile.EPUBCover(f io.ReaderAt, size int64) (img []byte, ext string, err error) // 无封面 → err
``` ```
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/bookfile/cover_test.go`(内存构造真 epub:container.xml + OPF + cover.png): `internal/bookfile/cover_test.go`(内存构造真 epub:container.xml + OPF + cover.png):
@@ -2398,7 +2398,7 @@ func TestEPUBNoCover(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 跑确认失败;实现 `cover.go`** - [x] **Step 2: 跑确认失败;实现 `cover.go`**
```go ```go
package bookfile package bookfile
@@ -2530,7 +2530,7 @@ func EPUBCover(f io.ReaderAt, size int64) ([]byte, string, error) {
(cover_test 中 `_ = url.PathUnescape` 那行不写,url 只在实现里 import。) (cover_test 中 `_ = url.PathUnescape` 那行不写,url 只在实现里 import。)
- [ ] **Step 3: 跑确认通过;Commit** - [x] **Step 3: 跑确认通过;Commit**
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
@@ -2561,7 +2561,7 @@ bookfile.SweepStale(cacheDir string, live map[string]bool) (int, error) // 删
衍生文件永不算旧即永不原地改:更新的书 hash 变 → 新 key 目录;旧 key 目录由清扫删除。 衍生文件永不算旧即永不原地改:更新的书 hash 变 → 新 key 目录;旧 key 目录由清扫删除。
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/bookfile/cache_test.go`: `internal/bookfile/cache_test.go`:
@@ -2621,7 +2621,7 @@ func TestPaths(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 跑确认失败;实现 `cache.go`** - [x] **Step 2: 跑确认失败;实现 `cache.go`**
```go ```go
package bookfile package bookfile
@@ -2687,7 +2687,7 @@ func (s *Store) BookHashes(ctx context.Context) (map[int64][2]int64, error) {
} }
``` ```
- [ ] **Step 3: 跑确认通过;Commit** - [x] **Step 3: 跑确认通过;Commit**
Run: `cd backend && go test ./internal/bookfile/ -v` → PASS Run: `cd backend && go test ./internal/bookfile/ -v` → PASS
@@ -2718,7 +2718,7 @@ NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scan
行为规则(spec §3.1/§6.1):锁内执行;单本失败 → `state=error` 继续;删行不删进度(path 键控);扫描尾清扫缓存。**不删磁盘文件**(删文件只有 API DELETE)。 行为规则(spec §3.1/§6.1):锁内执行;单本失败 → `state=error` 继续;删行不删进度(path 键控);扫描尾清扫缓存。**不删磁盘文件**(删文件只有 API DELETE)。
- [ ] **Step 1: 写失败测试 `scanner_test.go`(集成,依赖 PG;Redis 可为空走 no-op)** - [x] **Step 1: 写失败测试 `scanner_test.go`(集成,依赖 PG;Redis 可为空走 no-op)**
```go ```go
package scanner package scanner
@@ -2864,11 +2864,11 @@ func TestScanBrokenCBZStateError(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 跑确认失败** - [x] **Step 2: 跑确认失败**
Run: `cd backend && DATABASE_URL=... go test ./internal/scanner/ -v` → 编译失败 Run: `cd backend && DATABASE_URL=... go test ./internal/scanner/ -v` → 编译失败
- [ ] **Step 3: 实现 scanner** - [x] **Step 3: 实现 scanner**
`internal/scanner/scanner.go`: `internal/scanner/scanner.go`:
@@ -3126,7 +3126,7 @@ func (s *Scanner) sweepCache(ctx context.Context) {
坏 cbz 的判定规则已合并在上面 `add`/`update` 终版中:`PageIndex` 失败(含 `ErrNotZip`、`ErrUnsafeZip`,Task 6 已定义)→ `state=error`;cover 提取失败只 log。 坏 cbz 的判定规则已合并在上面 `add`/`update` 终版中:`PageIndex` 失败(含 `ErrNotZip`、`ErrUnsafeZip`,Task 6 已定义)→ `state=error`;cover 提取失败只 log。
- [ ] **Step 4: 接线 API** - [x] **Step 4: 接线 API**
`NewRouter` 加第 4 参 `sc *scanner.Scanner`,存进 `api` struct(`sc *scanner.Scanner`);`internal/api/libraries.go` 的 `scanLibrary` 整函数替换: `NewRouter` 加第 4 参 `sc *scanner.Scanner`,存进 `api` struct(`sc *scanner.Scanner`);`internal/api/libraries.go` 的 `scanLibrary` 整函数替换:
@@ -3153,7 +3153,7 @@ func (a *api) scanLibrary(c *gin.Context) {
(Task 10 会把 setupAPI 整体替换为 4 返回值终版,这里先保编译。)`api` struct 增加字段 `sc *scanner.Scanner`。 (Task 10 会把 setupAPI 整体替换为 4 返回值终版,这里先保编译。)`api` struct 增加字段 `sc *scanner.Scanner`。
- [ ] **Step 5: 跑全部后端测试确认通过;Commit** - [x] **Step 5: 跑全部后端测试确认通过;Commit**
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test -p 1 ./... -v` Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test -p 1 ./... -v`
Expected: 全 PASS Expected: 全 PASS
@@ -3217,7 +3217,7 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
既有 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`。 既有 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: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/api/books_test.go`(fixture 助手 `newLibrary/writeCBZ/scanNow/loginAs/adminToken` 定义在这里,Task 11/12 的测试文件复用): `internal/api/books_test.go`(fixture 助手 `newLibrary/writeCBZ/scanNow/loginAs/adminToken` 定义在这里,Task 11/12 的测试文件复用):
@@ -3372,7 +3372,7 @@ func loginAs(t *testing.T, h http.Handler, user, pass string) string {
`adminToken` 终版(users_test.go 中):`func adminToken(t *testing.T, h http.Handler) string { return loginAs(t, h, "alice", "pw12345") }`。 `adminToken` 终版(users_test.go 中):`func adminToken(t *testing.T, h http.Handler) string { return loginAs(t, h, "alice", "pw12345") }`。
- [ ] **Step 2: 跑确认失败;实现 books.go + 路由** - [x] **Step 2: 跑确认失败;实现 books.go + 路由**
`internal/api/books.go`: `internal/api/books.go`:
@@ -3521,7 +3521,7 @@ func (a *api) deleteBook(c *gin.Context) {
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook) p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
``` ```
- [ ] **Step 3: 跑确认通过;Commit** - [x] **Step 3: 跑确认通过;Commit**
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
@@ -3551,7 +3551,7 @@ GET /api/books/:id/pages/:n → 第 n 页(0-based),落盘缓存后吐;immutable
(*api) pageIndex(c, b store.Book, root string) ([]string, error) // redis pagesidx 缓存 7d (*api) pageIndex(c, b store.Book, root string) ([]string, error) // redis pagesidx 缓存 7d
``` ```
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/api/content_test.go`(fixture 复用 books_test 的 `newLibrary/writeCBZ/scanNow/loginAs/adminToken`): `internal/api/content_test.go`(fixture 复用 books_test 的 `newLibrary/writeCBZ/scanNow/loginAs/adminToken`):
@@ -3659,7 +3659,7 @@ func TestFileRangeETag(t *testing.T) {
(`content_test.go` import 需含 `net/http/httptest`、`encoding/json`、`strings`、`os`、`path/filepath`、`testing`、`net/http`。) (`content_test.go` import 需含 `net/http/httptest`、`encoding/json`、`strings`、`os`、`path/filepath`、`testing`、`net/http`。)
- [ ] **Step 2: 跑确认失败;实现 content.go** - [x] **Step 2: 跑确认失败;实现 content.go**
```go ```go
package api package api
@@ -3853,7 +3853,7 @@ router 追加:
p.GET("/books/:id/pages/:n", a.page) p.GET("/books/:id/pages/:n", a.page)
``` ```
- [ ] **Step 3: 跑确认通过;Commit** - [x] **Step 3: 跑确认通过;Commit**
Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./internal/api/ -v` → PASS Run: `cd backend && DATABASE_URL=... REDIS_URL=... go test ./internal/api/ -v` → PASS
@@ -3874,7 +3874,7 @@ git add backend && git commit -m "feat(backend): cover/file/pages endpoints —
- Consumes: Task 2/10 - Consumes: Task 2/10
- Produces:`PUT /api/books/:id/progress`、`GET /api/progress` - Produces:`PUT /api/books/:id/progress`、`GET /api/progress`
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`internal/api/progress_test.go`: `internal/api/progress_test.go`:
@@ -3959,7 +3959,7 @@ func TestProgressRoundTrip(t *testing.T) {
(progress_test 不用 context,import 列表不要 `context`。) (progress_test 不用 context,import 列表不要 `context`。)
- [ ] **Step 2: 跑确认失败;实现 progress.go** - [x] **Step 2: 跑确认失败;实现 progress.go**
```go ```go
package api package api
@@ -4028,7 +4028,7 @@ router 追加:
p.GET("/progress", a.listProgress) p.GET("/progress", a.listProgress)
``` ```
- [ ] **Step 3: 跑确认通过;Commit** - [x] **Step 3: 跑确认通过;Commit**
Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS Run: `cd backend && DATABASE_URL=... go test ./internal/api/ -v` → PASS
@@ -4049,7 +4049,7 @@ git add backend && git commit -m "feat(backend): reading progress upsert + listi
- Consumes: 全部 - Consumes: 全部
- Produces: 可执行 `/server`,env 驱动;`NewRouter` 最终签名不变。 - Produces: 可执行 `/server`,env 驱动;`NewRouter` 最终签名不变。
- [ ] **Step 1: config 单测(先失败)** - [x] **Step 1: config 单测(先失败)**
`internal/config/config_test.go`: `internal/config/config_test.go`:
@@ -4083,7 +4083,7 @@ func TestLoad(t *testing.T) {
} }
``` ```
- [ ] **Step 2: 实现 main.go** - [x] **Step 2: 实现 main.go**
`cmd/server/main.go`: `cmd/server/main.go`:
@@ -4149,7 +4149,7 @@ func main() {
} }
``` ```
- [ ] **Step 3: PG 不可用 → 503(spec §9 降级)** - [x] **Step 3: PG 不可用 → 503(spec §9 降级)**
`internal/api/api.go` 追加(`import` 补 `errors`、`io`、`log`、`net`、`syscall`、`github.com/jackc/pgx/v5/pgxpool`): `internal/api/api.go` 追加(`import` 补 `errors`、`io`、`log`、`net`、`syscall`、`github.com/jackc/pgx/v5/pgxpool`):
@@ -4168,7 +4168,7 @@ func dbErr(c *gin.Context, e 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 -p 1 ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。 把所有 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 -p 1 ./...` 全绿即可(既有测试不覆盖 PG 宕机路径)。
- [ ] **Step 4: 全量回归 + 本地起服烟测** - [x] **Step 4: 全量回归 + 本地起服烟测**
```bash ```bash
cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test -p 1 ./... cd backend && go vet ./... && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable REDIS_URL=redis://localhost:6380 go test -p 1 ./...
@@ -4187,7 +4187,7 @@ kill %1
Expected: 全 PASS + 末行输出含 `"title":"a"`。 Expected: 全 PASS + 末行输出含 `"title":"a"`。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add backend && git commit -m "feat(backend): main wiring, graceful shutdown, local smoke green" git add backend && git commit -m "feat(backend): main wiring, graceful shutdown, local smoke green"
@@ -4206,7 +4206,7 @@ git add backend && git commit -m "feat(backend): main wiring, graceful shutdown,
- Consumes: Task 13 的可运行服务 - Consumes: Task 13 的可运行服务
- Produces: `docker compose up -d --scale api=2` 起 web/api×2/pg/redis;`scripts/smoke.sh` 一条命令验收整个后端 - Produces: `docker compose up -d --scale api=2` 起 web/api×2/pg/redis;`scripts/smoke.sh` 一条命令验收整个后端
- [ ] **Step 1: 部署文件** - [x] **Step 1: 部署文件**
`deploy/Dockerfile.api`: `deploy/Dockerfile.api`:
@@ -4309,7 +4309,7 @@ ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60 SCAN_INTERVAL_SEC=60
``` ```
- [ ] **Step 2: 冒烟脚本 `scripts/smoke.sh`** - [x] **Step 2: 冒烟脚本 `scripts/smoke.sh`**
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -4370,7 +4370,7 @@ curl -fsS -o /dev/null -X DELETE "$API/books/$BID" -H "$AUTH" || die "delete"
say "ALL SMOKE TESTS PASSED" say "ALL SMOKE TESTS PASSED"
``` ```
- [ ] **Step 3: 跑通** - [x] **Step 3: 跑通**
```bash ```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 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
@@ -4382,7 +4382,7 @@ docker compose down -v && rm -f library_upload_note.txt
Expected: `ALL SMOKE TESTS PASSED`。(Linux 上 `sed -i` 去掉 `''` 参数。) Expected: `ALL SMOKE TESTS PASSED`。(Linux 上 `sed -i` 去掉 `''` 参数。)
- [ ] **Step 4: Commit** - [x] **Step 4: Commit**
```bash ```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" 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"
+74 -74
View File
@@ -110,7 +110,7 @@ README.md # Task 12:补前端 dev/部署说明
- Consumes: 无 - Consumes: 无
- Produces: 可 `npm run check` 的工程;`npm run dev`(:5173,`/api` 代理到 :8080) - Produces: 可 `npm run check` 的工程;`npm run dev`(:5173,`/api` 代理到 :8080)
- [ ] **Step 1: 初始化工程并装依赖(一次性,后续任务不再装)** - [x] **Step 1: 初始化工程并装依赖(一次性,后续任务不再装)**
```bash ```bash
cd web cd web
@@ -124,7 +124,7 @@ npm install -D typescript vite @vitejs/plugin-react tailwindcss @tailwindcss/vit
Expected: 无 error 级输出;`node_modules`、`package-lock.json` 生成。epubjs 锁 0.3.93(上游停更,锁死可复现);其余用 latest。若 `npm install` 报 ERESOLVE(vite-plugin-pwa 与已装 vite 大版本 peer 冲突):降级安装最后一个兼容版 `npm i -D vite-plugin-pwa@0.21.2`,Task 12 的 `VitePWA({...})` 配置键在 0.21→1.x 间未变,后续代码不用改。 Expected: 无 error 级输出;`node_modules`、`package-lock.json` 生成。epubjs 锁 0.3.93(上游停更,锁死可复现);其余用 latest。若 `npm install` 报 ERESOLVE(vite-plugin-pwa 与已装 vite 大版本 peer 冲突):降级安装最后一个兼容版 `npm i -D vite-plugin-pwa@0.21.2`,Task 12 的 `VitePWA({...})` 配置键在 0.21→1.x 间未变,后续代码不用改。
- [ ] **Step 2: 写配置文件** - [x] **Step 2: 写配置文件**
`web/.gitignore`: `web/.gitignore`:
@@ -180,7 +180,7 @@ export default defineConfig({
}); });
``` ```
- [ ] **Step 3: 写入口文件** - [x] **Step 3: 写入口文件**
`web/index.html`: `web/index.html`:
@@ -247,12 +247,12 @@ export default function App() {
} }
``` ```
- [ ] **Step 4: 跑门槛确认通过** - [x] **Step 4: 跑门槛确认通过**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。 Expected: `tsc` 无错、vitest `No test files匹配 → passWithNoTests OK`、`vite build` 产出 `dist/`。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): vite+react+ts+tailwind4 scaffold, vitest, /api dev proxy" git add web && git commit -m "feat(web): vite+react+ts+tailwind4 scaffold, vitest, /api dev proxy"
@@ -313,7 +313,7 @@ export const api: {
} }
``` ```
- [ ] **Step 1: 写失败测试 `web/test/client.test.ts`** - [x] **Step 1: 写失败测试 `web/test/client.test.ts`**
```ts ```ts
import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { afterEach, beforeEach, expect, it, vi } from "vitest";
@@ -380,12 +380,12 @@ it("putProgress keepalive 透传 + JSON body", async () => {
}); });
``` ```
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/client.test.ts` Run: `cd web && npx vitest run test/client.test.ts`
Expected: FAIL(`../src/api/client` 不存在) Expected: FAIL(`../src/api/client` 不存在)
- [ ] **Step 3: 实现 `src/api/types.ts` 与 `src/api/client.ts`** - [x] **Step 3: 实现 `src/api/types.ts` 与 `src/api/client.ts`**
`src/api/types.ts` — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 `export` 每行): `src/api/types.ts` — 内容即 Interfaces 块中 types.ts 声明,原样落成(加 `export` 每行):
@@ -600,12 +600,12 @@ export const api = {
}; };
``` ```
- [ ] **Step 4: 跑门槛确认通过** - [x] **Step 4: 跑门槛确认通过**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: vitest 7 个 case 全 PASS,tsc/build 无错。 Expected: vitest 7 个 case 全 PASS,tsc/build 无错。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): typed api client with bearer token, 401 logout, binary channels" git add web && git commit -m "feat(web): typed api client with bearer token, 401 logout, binary channels"
@@ -630,7 +630,7 @@ git add web && git commit -m "feat(web): typed api client with bearer token, 401
UI 装配任务无纯逻辑单测,门槛 = `npm run check`。 UI 装配任务无纯逻辑单测,门槛 = `npm run check`。
- [ ] **Step 1: 实现 `src/lib/qc.ts`** - [x] **Step 1: 实现 `src/lib/qc.ts`**
```ts ```ts
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
@@ -640,7 +640,7 @@ export const queryClient = new QueryClient({
}); });
``` ```
- [ ] **Step 2: 实现 `src/components/ui.ts`** - [x] **Step 2: 实现 `src/components/ui.ts`**
```ts ```ts
export const btn = export const btn =
@@ -652,7 +652,7 @@ export const input =
export const card = "rounded-xl border border-zinc-800 bg-zinc-900"; export const card = "rounded-xl border border-zinc-800 bg-zinc-900";
``` ```
- [ ] **Step 3: 实现 `src/components/Toaster.tsx`** - [x] **Step 3: 实现 `src/components/Toaster.tsx`**
```tsx ```tsx
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
@@ -700,7 +700,7 @@ export function Toaster() {
} }
``` ```
- [ ] **Step 4: 实现 `src/components/ErrorBoundary.tsx`** - [x] **Step 4: 实现 `src/components/ErrorBoundary.tsx`**
```tsx ```tsx
import { Component, type ReactNode } from "react"; import { Component, type ReactNode } from "react";
@@ -731,7 +731,7 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
} }
``` ```
- [ ] **Step 5: 实现 `src/auth/AuthContext.tsx`** - [x] **Step 5: 实现 `src/auth/AuthContext.tsx`**
```tsx ```tsx
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -808,7 +808,7 @@ export function RequireAdmin({ children }: { children: ReactNode }) {
} }
``` ```
- [ ] **Step 6: 实现 `src/pages/Login.tsx`** - [x] **Step 6: 实现 `src/pages/Login.tsx`**
```tsx ```tsx
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
@@ -857,7 +857,7 @@ export default function Login() {
} }
``` ```
- [ ] **Step 7: 替换 `src/main.tsx` 与 `src/App.tsx`** - [x] **Step 7: 替换 `src/main.tsx` 与 `src/App.tsx`**
`src/main.tsx`: `src/main.tsx`:
@@ -914,12 +914,12 @@ export default function App() {
} }
``` ```
- [ ] **Step 8: 门槛 + 人工冒烟(可选)** - [x] **Step 8: 门槛 + 人工冒烟(可选)**
Run: `cd web && npm run check` → 全绿。 Run: `cd web && npm run check` → 全绿。
人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。 人工(有环境时):起 dev 后端 + `npm run dev`,浏览器访问 :5173,未登录应跳 `/login`,错误密码出红色 toast。
- [ ] **Step 9: Commit** - [x] **Step 9: Commit**
```bash ```bash
git add web && git commit -m "feat(web): auth context, login page, route guards, toast/error shell" git add web && git commit -m "feat(web): auth context, login page, route guards, toast/error shell"
@@ -941,7 +941,7 @@ git add web && git commit -m "feat(web): auth context, login page, route guards,
- `Cover({ book, className })`(3:4 占位、失败显示重试按钮,spec §9) - `Cover({ book, className })`(3:4 占位、失败显示重试按钮,spec §9)
- `group.ts`:`interface Group { dir: string; books: Book[] }`;`groupByDir(books: Book[]): Group[]` - `group.ts`:`interface Group { dir: string; books: Book[] }`;`groupByDir(books: Book[]): Group[]`
- [ ] **Step 1: 写失败测试** - [x] **Step 1: 写失败测试**
`test/group.test.ts`: `test/group.test.ts`:
@@ -1005,12 +1005,12 @@ it("失败不入缓存,重试重新请求", async () => {
}); });
``` ```
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/group.test.ts test/authImage.test.ts` Run: `cd web && npx vitest run test/group.test.ts test/authImage.test.ts`
Expected: FAIL(模块不存在) Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现 `src/lib/group.ts`** - [x] **Step 3: 实现 `src/lib/group.ts`**
```ts ```ts
import type { Book } from "../api/types"; import type { Book } from "../api/types";
@@ -1038,7 +1038,7 @@ export function groupByDir(books: Book[]): Group[] {
} }
``` ```
- [ ] **Step 4: 实现 `src/lib/authImage.ts` 与 `src/components/Cover.tsx`** - [x] **Step 4: 实现 `src/lib/authImage.ts` 与 `src/components/Cover.tsx`**
`src/lib/authImage.ts`: `src/lib/authImage.ts`:
@@ -1120,12 +1120,12 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
} }
``` ```
- [ ] **Step 5: 跑门槛确认通过** - [x] **Step 5: 跑门槛确认通过**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 新增 4 个用例 PASS;build 无错。 Expected: 新增 4 个用例 PASS;build 无错。
- [ ] **Step 6: Commit** - [x] **Step 6: Commit**
```bash ```bash
git add web && git commit -m "feat(web): authed blob image pipeline with LRU, cover card, dir grouping" git add web && git commit -m "feat(web): authed blob image pipeline with LRU, cover card, dir grouping"
@@ -1143,7 +1143,7 @@ git add web && git commit -m "feat(web): authed blob image pipeline with LRU, co
- Consumes: Task 2 `api`;Task 3 `useAuth`、`btn/btnPrimary/input/card`;Task 4 `Cover`、`groupByDir`;`queryClient` - Consumes: Task 2 `api`;Task 3 `useAuth`、`btn/btnPrimary/input/card`;Task 4 `Cover`、`groupByDir`;`queryClient`
- Produces: 路由 `/`(书架);`TopBar({ right?: ReactNode })`(admin 页在 Task 11 复用);TanStack Query key 约定:`["libraries"]`、`["books", libId, q]`、`["progress"]`、`["book", id]` - Produces: 路由 `/`(书架);`TopBar({ right?: ReactNode })`(admin 页在 Task 11 复用);TanStack Query key 约定:`["libraries"]`、`["books", libId, q]`、`["progress"]`、`["book", id]`
- [ ] **Step 1: 实现 `src/components/TopBar.tsx`** - [x] **Step 1: 实现 `src/components/TopBar.tsx`**
```tsx ```tsx
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -1183,7 +1183,7 @@ export function TopBar({ right }: { right?: ReactNode }) {
} }
``` ```
- [ ] **Step 2: 实现 `src/pages/Shelf.tsx`** - [x] **Step 2: 实现 `src/pages/Shelf.tsx`**
```tsx ```tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -1384,7 +1384,7 @@ export default function Shelf() {
} }
``` ```
- [ ] **Step 3: 替换 `src/App.tsx`** - [x] **Step 3: 替换 `src/App.tsx`**
```tsx ```tsx
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
@@ -1429,12 +1429,12 @@ export default function App() {
} }
``` ```
- [ ] **Step 4: 门槛** - [x] **Step 4: 门槛**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 全绿。 Expected: 全绿。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): shelf with library tabs, search, dir groups, covers, continue-reading, admin delete/scan" git add web && git commit -m "feat(web): shelf with library tabs, search, dir groups, covers, continue-reading, admin delete/scan"
@@ -1456,7 +1456,7 @@ git add web && git commit -m "feat(web): shelf with library tabs, search, dir gr
- `lib/useProgress.ts`:`interface ReaderProps { book: Book; initialLocator?: Record<string, unknown> }`;`useProgressSaver(bookId: number): ProgressSaver`(自动挂 pagehide/visibilitychange→flush) - `lib/useProgress.ts`:`interface ReaderProps { book: Book; initialLocator?: Record<string, unknown> }`;`useProgressSaver(bookId: number): ProgressSaver`(自动挂 pagehide/visibilitychange→flush)
- `pages/Reader.tsx`:`/book/:id` 壳;4 个 reader 任务各向 `READERS` 表加一行 lazy - `pages/Reader.tsx`:`/book/:id` 壳;4 个 reader 任务各向 `READERS` 表加一行 lazy
- [ ] **Step 1: 写失败测试 `test/progress.test.ts`** - [x] **Step 1: 写失败测试 `test/progress.test.ts`**
```ts ```ts
import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { afterEach, beforeEach, expect, it, vi } from "vitest";
@@ -1503,12 +1503,12 @@ it("flush 立刻尾随发送且 keepalive=true;重复值去重;清空挂起", ()
}); });
``` ```
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/progress.test.ts` Run: `cd web && npx vitest run test/progress.test.ts`
Expected: FAIL(模块不存在) Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现 `src/lib/progress.ts`** - [x] **Step 3: 实现 `src/lib/progress.ts`**
```ts ```ts
export type ProgressSend = ( export type ProgressSend = (
@@ -1570,7 +1570,7 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
} }
``` ```
- [ ] **Step 4: 实现 `src/lib/useProgress.ts`** - [x] **Step 4: 实现 `src/lib/useProgress.ts`**
```ts ```ts
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -1613,7 +1613,7 @@ export function useProgressSaver(bookId: number): ProgressSaver {
} }
``` ```
- [ ] **Step 5: 实现 `src/pages/Reader.tsx`** - [x] **Step 5: 实现 `src/pages/Reader.tsx`**
每个 reader 任务会向 `READERS` 加一行(本任务是空表 → 一律"未知格式"占位,先验证壳)。`initialLocator` 由壳统一解析(等 `["progress"]` 加载完再挂载 reader,消除恢复竞态)。 每个 reader 任务会向 `READERS` 加一行(本任务是空表 → 一律"未知格式"占位,先验证壳)。`initialLocator` 由壳统一解析(等 `["progress"]` 加载完再挂载 reader,消除恢复竞态)。
@@ -1716,7 +1716,7 @@ function Msg({ text, retry }: { text: string; retry?: () => void }) {
} }
``` ```
- [ ] **Step 6: 替换 `src/App.tsx`** - [x] **Step 6: 替换 `src/App.tsx`**
```tsx ```tsx
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
@@ -1758,12 +1758,12 @@ export default function App() {
} }
``` ```
- [ ] **Step 7: 跑门槛确认通过** - [x] **Step 7: 跑门槛确认通过**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: progress 2 用例 PASS;build 无错(空 READERS 是合法态)。 Expected: progress 2 用例 PASS;build 无错(空 READERS 是合法态)。
- [ ] **Step 8: Commit** - [x] **Step 8: Commit**
```bash ```bash
git add web && git commit -m "feat(web): throttled progress saver with keepalive flush, reader dispatch shell" git add web && git commit -m "feat(web): throttled progress saver with keepalive flush, reader dispatch shell"
@@ -1782,7 +1782,7 @@ git add web && git commit -m "feat(web): throttled progress saver with keepalive
- Consumes: `api.pageCount(book.pages_url)`→`{count}`;`formatPageUrl(book.page_url_fmt, n)`;`fetchObjectUrl`;`useProgressSaver`/`ReaderProps` - Consumes: `api.pageCount(book.pages_url)`→`{count}`;`formatPageUrl(book.page_url_fmt, n)`;`fetchObjectUrl`;`useProgressSaver`/`ReaderProps`
- Produces: `lib/virt.ts`:`class PageHeights { constructor(n: number, est: number); set(i: number, h: number): number /* 高度增量 */; offset(i: number): number; total(): number; pageAt(y: number): number; range(scrollTop: number, viewport: number, overscan: number): [number, number] /* [start, end) */ }`;`readers/CbzReader.tsx` default export `(p: ReaderProps) => JSX`,locator `{page}`(0 基),percent `(page+1)/count` - Produces: `lib/virt.ts`:`class PageHeights { constructor(n: number, est: number); set(i: number, h: number): number /* 高度增量 */; offset(i: number): number; total(): number; pageAt(y: number): number; range(scrollTop: number, viewport: number, overscan: number): [number, number] /* [start, end) */ }`;`readers/CbzReader.tsx` default export `(p: ReaderProps) => JSX`,locator `{page}`(0 基),percent `(page+1)/count`
- [ ] **Step 1: 写失败测试 `test/virt.test.ts`** - [x] **Step 1: 写失败测试 `test/virt.test.ts`**
```ts ```ts
import { expect, it } from "vitest"; import { expect, it } from "vitest";
@@ -1818,12 +1818,12 @@ it("n=0 时一切归空", () => {
}); });
``` ```
- [ ] **Step 2: 跑,确认失败** - [x] **Step 2: 跑,确认失败**
Run: `cd web && npx vitest run test/virt.test.ts` Run: `cd web && npx vitest run test/virt.test.ts`
Expected: FAIL(模块不存在) Expected: FAIL(模块不存在)
- [ ] **Step 3: 实现 `src/lib/virt.ts`** - [x] **Step 3: 实现 `src/lib/virt.ts`**
```ts ```ts
// CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正, // CBZ 竖滚虚拟列表的高度模型:估高起步,页图加载完用真实高校正,
@@ -1891,7 +1891,7 @@ export class PageHeights {
} }
``` ```
- [ ] **Step 4: 实现 `src/readers/CbzReader.tsx`** - [x] **Step 4: 实现 `src/readers/CbzReader.tsx`**
```tsx ```tsx
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -2039,7 +2039,7 @@ export default function CbzReader({ book, initialLocator }: ReaderProps) {
} }
``` ```
- [ ] **Step 5: `src/pages/Reader.tsx` 接线 cbz** - [x] **Step 5: `src/pages/Reader.tsx` 接线 cbz**
先把 react import 行还原为含 `lazy`(见 Task 6 注释),再整块替换 READERS: 先把 react import 行还原为含 `lazy`(见 Task 6 注释),再整块替换 READERS:
@@ -2049,12 +2049,12 @@ const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderPr
}; };
``` ```
- [ ] **Step 6: 跑门槛确认通过** - [x] **Step 6: 跑门槛确认通过**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: virt 3 用例 PASS;build 无错。 Expected: virt 3 用例 PASS;build 无错。
- [ ] **Step 7: Commit** - [x] **Step 7: Commit**
```bash ```bash
git add web && git commit -m "feat(web): cbz reader — virtual scroll with height correction, prefetch, progress restore" git add web && git commit -m "feat(web): cbz reader — virtual scroll with height correction, prefetch, progress restore"
@@ -2072,7 +2072,7 @@ git add web && git commit -m "feat(web): cbz reader — virtual scroll with heig
- Consumes: `apiRaw(book.file_url).text()`;`useProgressSaver`/`ReaderProps` - Consumes: `apiRaw(book.file_url).text()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/TextReader.tsx` default export `(p: ReaderProps)`,同时服务 txt 与 md;locator `{scrollFraction}`(0..1),percent 同值 - Produces: `readers/TextReader.tsx` default export `(p: ReaderProps)`,同时服务 txt 与 md;locator `{scrollFraction}`(0..1),percent 同值
- [ ] **Step 1: 实现 `src/readers/TextReader.tsx`** - [x] **Step 1: 实现 `src/readers/TextReader.tsx`**
```tsx ```tsx
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
@@ -2151,7 +2151,7 @@ export default function TextReader({ book, initialLocator }: ReaderProps) {
} }
``` ```
- [ ] **Step 2: `index.css` 末尾追加 md 正文样式(Tailwind4 无 typography 插件,不新增依赖)** - [x] **Step 2: `index.css` 末尾追加 md 正文样式(Tailwind4 无 typography 插件,不新增依赖)**
```css ```css
/* markdown 正文(仅 .md-body 作用域) */ /* markdown 正文(仅 .md-body 作用域) */
@@ -2172,7 +2172,7 @@ export default function TextReader({ book, initialLocator }: ReaderProps) {
.md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; } .md-body th, .md-body td { @apply border border-zinc-700 px-2 py-1 text-left; }
``` ```
- [ ] **Step 3: `src/pages/Reader.tsx` 接线** - [x] **Step 3: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换: `READERS` 整块替换:
@@ -2184,12 +2184,12 @@ const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderPr
}; };
``` ```
- [ ] **Step 4: 门槛** - [x] **Step 4: 门槛**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 全绿(vitest 无新增;门槛=编译+构建)。 Expected: 全绿(vitest 无新增;门槛=编译+构建)。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): txt/md reader with dompurified markdown and scroll-fraction progress" git add web && git commit -m "feat(web): txt/md reader with dompurified markdown and scroll-fraction progress"
@@ -2207,7 +2207,7 @@ git add web && git commit -m "feat(web): txt/md reader with dompurified markdown
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps` - Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/PdfReader.tsx` default export `(p: ReaderProps)`;locator `{page}`(0 基,与 CBZ 统一 0 基),percent `(page+1)/numPages` - Produces: `readers/PdfReader.tsx` default export `(p: ReaderProps)`;locator `{page}`(0 基,与 CBZ 统一 0 基),percent `(page+1)/numPages`
- [ ] **Step 1: 实现 `src/readers/PdfReader.tsx`** - [x] **Step 1: 实现 `src/readers/PdfReader.tsx`**
```tsx ```tsx
import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { useEffect, useRef, useState, type KeyboardEvent } from "react";
@@ -2360,7 +2360,7 @@ export default function PdfReader({ book, initialLocator }: ReaderProps) {
} }
``` ```
- [ ] **Step 2: `src/pages/Reader.tsx` 接线** - [x] **Step 2: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换: `READERS` 整块替换:
@@ -2373,12 +2373,12 @@ const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderPr
}; };
``` ```
- [ ] **Step 3: 门槛** - [x] **Step 3: 门槛**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 全绿。若 `p.render({canvasContext, viewport})` 在已装 pdfjs-dist 大版本下类型不符(历史上参数名有 `canvas`/`canvasContext` 差异),按 node_modules 内 `types/display.d.xml`→`RenderParameters` 实际字段名调整这一处,**不改其余逻辑**。 Expected: 全绿。若 `p.render({canvasContext, viewport})` 在已装 pdfjs-dist 大版本下类型不符(历史上参数名有 `canvas`/`canvasContext` 差异),按 node_modules 内 `types/display.d.xml`→`RenderParameters` 实际字段名调整这一处,**不改其余逻辑**。
- [ ] **Step 4: Commit** - [x] **Step 4: Commit**
```bash ```bash
git add web && git commit -m "feat(web): pdf reader with dpr-aware canvas rendering and keyboard paging" git add web && git commit -m "feat(web): pdf reader with dpr-aware canvas rendering and keyboard paging"
@@ -2396,13 +2396,13 @@ git add web && git commit -m "feat(web): pdf reader with dpr-aware canvas render
- Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps` - Consumes: `apiRaw(book.file_url).arrayBuffer()`;`useProgressSaver`/`ReaderProps`
- Produces: `readers/EpubReader.tsx` default export `(p: ReaderProps)`;locator `{cfi}`,percent `(sectionIndex+1)/spineLength` - Produces: `readers/EpubReader.tsx` default export `(p: ReaderProps)`;locator `{cfi}`,percent `(sectionIndex+1)/spineLength`
- [ ] **Step 1: 写 `src/types/shims.d.ts`(epubjs 0.3 类型不完备,兜底声明,包内自带类型时自动失效)** - [x] **Step 1: 写 `src/types/shims.d.ts`(epubjs 0.3 类型不完备,兜底声明,包内自带类型时自动失效)**
```ts ```ts
declare module "epubjs"; declare module "epubjs";
``` ```
- [ ] **Step 2: 实现 `src/readers/EpubReader.tsx`** - [x] **Step 2: 实现 `src/readers/EpubReader.tsx`**
```tsx ```tsx
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@@ -2509,7 +2509,7 @@ export default function EpubReader({ book, initialLocator }: ReaderProps) {
} }
``` ```
- [ ] **Step 3: `src/pages/Reader.tsx` 接线** - [x] **Step 3: `src/pages/Reader.tsx` 接线**
`READERS` 整块替换: `READERS` 整块替换:
@@ -2523,12 +2523,12 @@ const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderPr
}; };
``` ```
- [ ] **Step 4: 门槛** - [x] **Step 4: 门槛**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 全绿。 Expected: 全绿。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): epub reader via epubjs arraybuffer bootstrap with cfi progress" git add web && git commit -m "feat(web): epub reader via epubjs arraybuffer bootstrap with cfi progress"
@@ -2546,7 +2546,7 @@ git add web && git commit -m "feat(web): epub reader via epubjs arraybuffer boot
- Consumes: Task 2 `api.*`;Task 3 `useAuth/RequireAdmin`;Task 5 `TopBar`、ui 常量 - Consumes: Task 2 `api.*`;Task 3 `useAuth/RequireAdmin`;Task 5 `TopBar`、ui 常量
- Produces: 路由 `/admin/users`、`/admin/libraries`(TopBar 已挂链接);query key `["users"]` - Produces: 路由 `/admin/users`、`/admin/libraries`(TopBar 已挂链接);query key `["users"]`
- [ ] **Step 1: 实现 `src/pages/admin/Users.tsx`** - [x] **Step 1: 实现 `src/pages/admin/Users.tsx`**
```tsx ```tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -2644,7 +2644,7 @@ export default function AdminUsers() {
} }
``` ```
- [ ] **Step 2: 实现 `src/pages/admin/Libraries.tsx`** - [x] **Step 2: 实现 `src/pages/admin/Libraries.tsx`**
```tsx ```tsx
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -2756,7 +2756,7 @@ export default function AdminLibraries() {
} }
``` ```
- [ ] **Step 3: 替换 `src/App.tsx`(补 admin 路由)** - [x] **Step 3: 替换 `src/App.tsx`(补 admin 路由)**
```tsx ```tsx
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
@@ -2820,12 +2820,12 @@ export default function App() {
} }
``` ```
- [ ] **Step 4: 门槛** - [x] **Step 4: 门槛**
Run: `cd web && npm run check` Run: `cd web && npm run check`
Expected: 全绿。人工(可选):member 登录看不到 admin 链接,直敲 /admin/users 被弹回 `/`。 Expected: 全绿。人工(可选):member 登录看不到 admin 链接,直敲 /admin/users 被弹回 `/`。
- [ ] **Step 5: Commit** - [x] **Step 5: Commit**
```bash ```bash
git add web && git commit -m "feat(web): admin users CRUD + libraries create/scan/upload" git add web && git commit -m "feat(web): admin users CRUD + libraries create/scan/upload"
@@ -2844,7 +2844,7 @@ git add web && git commit -m "feat(web): admin users CRUD + libraries create/sca
- Consumes: Task 1-11 的全部产物;`web/dist`(vite build) - Consumes: Task 1-11 的全部产物;`web/dist`(vite build)
- Produces: 生产镜像 = SPA + nginx(`/api/` 反代);SW:不可变资源 cache-first、其余 /api network-first(spec §6.3) - Produces: 生产镜像 = SPA + nginx(`/api/` 反代);SW:不可变资源 cache-first、其余 /api network-first(spec §6.3)
- [ ] **Step 1: 替换 `web/vite.config.ts`** - [x] **Step 1: 替换 `web/vite.config.ts`**
```ts ```ts
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
@@ -2899,13 +2899,13 @@ export default defineConfig({
}); });
``` ```
- [ ] **Step 2: `web/tsconfig.json` 的 `"types"` 行替换** - [x] **Step 2: `web/tsconfig.json` 的 `"types"` 行替换**
```json ```json
"types": ["vite/client", "vite-plugin-pwa/client"], "types": ["vite/client", "vite-plugin-pwa/client"],
``` ```
- [ ] **Step 3: 新建 `web/src/pwa.ts` 并在 `main.tsx` 引入** - [x] **Step 3: 新建 `web/src/pwa.ts` 并在 `main.tsx` 引入**
`src/pwa.ts`: `src/pwa.ts`:
@@ -2922,7 +2922,7 @@ registerSW({ immediate: true });
import "./pwa"; import "./pwa";
``` ```
- [ ] **Step 4: `src/auth/AuthContext.tsx` logout 加清 SW 缓存(同设备多账号隔离)** - [x] **Step 4: `src/auth/AuthContext.tsx` logout 加清 SW 缓存(同设备多账号隔离)**
`logout` 实现整块替换为: `logout` 实现整块替换为:
@@ -2938,12 +2938,12 @@ import "./pwa";
}, },
``` ```
- [ ] **Step 5: 构建验证 SW 产物** - [x] **Step 5: 构建验证 SW 产物**
Run: `cd web && npm run check && ls dist | grep -E 'sw\.js|workbox|manifest'` Run: `cd web && npm run check && ls dist | grep -E 'sw\.js|workbox|manifest'`
Expected: check 全绿;列出 `sw.js`、`workbox-*.js`、`manifest.webmanifest`。 Expected: check 全绿;列出 `sw.js`、`workbox-*.js`、`manifest.webmanifest`。
- [ ] **Step 6: 替换 `deploy/Dockerfile.web`、删除占位、补 .dockerignore** - [x] **Step 6: 替换 `deploy/Dockerfile.web`、删除占位、补 .dockerignore**
``` ```
FROM node:22-alpine AS build FROM node:22-alpine AS build
@@ -2970,7 +2970,7 @@ web/dist
web/dev-dist web/dev-dist
``` ```
- [ ] **Step 7: 写全栈冒烟 `scripts/smoke-web.sh`** - [x] **Step 7: 写全栈冒烟 `scripts/smoke-web.sh`**
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
@@ -3015,7 +3015,7 @@ curl -fsS "$BASE/manifest.webmanifest" >/dev/null || die "manifest 缺失"
say "ALL WEB SMOKE PASSED" say "ALL WEB SMOKE PASSED"
``` ```
- [ ] **Step 8: 跑冒烟** - [x] **Step 8: 跑冒烟**
```bash ```bash
chmod +x scripts/smoke-web.sh chmod +x scripts/smoke-web.sh
@@ -3026,7 +3026,7 @@ docker compose down
Expected: `ALL WEB SMOKE PASSED`(compose 栈与 Plan 1 `scripts/smoke.sh` 共用,若后端栈在跑先 down)。 Expected: `ALL WEB SMOKE PASSED`(compose 栈与 Plan 1 `scripts/smoke.sh` 共用,若后端栈在跑先 down)。
- [ ] **Step 9: README 追加前端章节** - [x] **Step 9: README 追加前端章节**
在 `README.md` 末尾追加(若已有相关章节则合并去重,不重复端口说明): 在 `README.md` 末尾追加(若已有相关章节则合并去重,不重复端口说明):
@@ -3047,7 +3047,7 @@ Expected: `ALL WEB SMOKE PASSED`(compose 栈与 Plan 1 `scripts/smoke.sh` 共用
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。 - 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
``` ```
- [ ] **Step 10: 最终门槛 + Commit** - [x] **Step 10: 最终门槛 + Commit**
```bash ```bash
cd web && npm run check && cd .. cd web && npm run check && cd ..
@@ -1,7 +1,7 @@
# 个人书库 / 漫画库 — 设计文档 # 个人书库 / 漫画库 — 设计文档
日期:2026-09-04 日期:2026-09-04
状态:待评审 状态:已实现(Plan 1 backend+deploy、Plan 2 frontend 均已合并至 master)
## 1. 概述 ## 1. 概述