Compare commits

76 Commits
Author SHA1 Message Date
XingfenD ef57b194f4 chore(frontend): tighten eslint/axe gates, remove reader waivers (B5 done, spec ② complete)
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 19:27:25 +08:00
XingfenD 395be9688d refactor(frontend): remove ui.ts/icons.tsx and all rd-* legacy classes (B5) 2026-09-16 17:53:51 +08:00
XingfenD 28d8587e71 feat(frontend): shelf reading stats card + session tracking (B4) 2026-09-16 17:49:12 +08:00
XingfenD 4767ee7fec refactor(frontend): PDF reader chrome to shadcn (B4) 2026-09-16 17:43:51 +08:00
XingfenD 8831a9b277 feat(frontend): EPUB typography themes + chrome migration (B3) 2026-09-16 17:41:35 +08:00
XingfenD 272d0bc672 test(frontend): text search e2e with txt fixture (B2) 2026-09-16 17:37:50 +08:00
XingfenD 1004daf821 feat(frontend): text reader search + typography, chrome migration (B2) 2026-09-16 16:58:39 +08:00
XingfenD 74e5e9d5fe feat(frontend): in-book text search core (TDD) 2026-09-16 16:51:36 +08:00
XingfenD 0d7cd5f322 chore(frontend): remove debug probe artifact 2026-09-16 16:50:59 +08:00
XingfenD 174c498757 test(frontend): CBZ page-mode component test + e2e mode/RTL smoke (B1) 2026-09-16 16:50:55 +08:00
XingfenD 26aafb3f53 feat(frontend): CBZ page/spread modes + RTL, chrome migration (B1) 2026-09-16 16:20:08 +08:00
XingfenD cea2414371 refactor(frontend): shared reader chrome to shadcn/radix (header, nav Sheet, bookmarks) 2026-09-16 15:46:07 +08:00
XingfenD 1f9d7e7572 feat(frontend): cbz page/spread view math (TDD) 2026-09-16 15:22:59 +08:00
XingfenD 996f492083 feat(frontend): reading stats store (TDD) + session heartbeat hook (B0) 2026-09-16 15:22:38 +08:00
XingfenD 6aefe62a9f feat(frontend): unified reader settings bar with theme linkage (B0) 2026-09-16 15:20:18 +08:00
XingfenD 8283d66db6 feat(frontend): shadcn sheet/tabs/slider primitives (B0) 2026-09-16 15:17:26 +08:00
XingfenD 655b77c2b7 feat(frontend): reading theme token group + useGlobalDark for auto linkage (B0) 2026-09-16 15:15:53 +08:00
XingfenD 64cc293878 feat(frontend): readerPrefs v2 unified structure (TDD) 2026-09-16 15:15:08 +08:00
XingfenD 72cf13c8c3 chore(frontend): extend tsc coverage to e2e/configs, tighten vitest include (B0) 2026-09-16 15:13:35 +08:00
XingfenD 154ef11c90 docs: reader revamp implementation plan (18 tasks, 6 batches) 2026-09-16 15:10:02 +08:00
XingfenD 043b7fc480 docs: reader revamp spec (subproject ②) 2026-09-16 14:53:59 +08:00
XingfenD cc18822c97 Merge remote-tracking branch 'origin/feat/frontend-quality'
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 12:16:40 +08:00
XingfenD 2fccf3f2b3 docs: record TS side-by-side + legacy-peer-deps conventions (AGENTS.md + .npmrc)
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-16 11:43:21 +08:00
XingfenD caacaf9c30 docs: changelog for component testing + bundle review (B4 done, spec ④ complete) 2026-09-16 11:28:12 +08:00
XingfenD 5ba522753a docs: README frontend gates (lint/e2e/analyze), bilingual 2026-09-16 11:21:35 +08:00
XingfenD 8f4c34c949 docs: bundle-review — align dependency sizes to source-level caliber, rewrite epubjs finding 2026-09-16 11:18:44 +08:00
XingfenD 0bf8129f6e docs: bundle review findings (B4) 2026-09-16 11:06:58 +08:00
XingfenD f5c33f538e chore(frontend): bundle analyzer via npm run analyze (B4) 2026-09-16 11:00:22 +08:00
XingfenD cd02a88b59 ci(e2e): render configs from templates before compose up 2026-09-16 10:56:47 +08:00
XingfenD 4ba55fcf2a ci: e2e workflow (workflow_dispatch) + changelog (B3 done) 2026-09-16 10:53:23 +08:00
XingfenD f2f06a516b test(frontend): e2e admin smoke (create library + scan) 2026-09-16 10:48:23 +08:00
XingfenD 9c6339d35e test(frontend): e2e main flow (login→shelf→cbz→bookmark→logout) + axe scans 2026-09-15 17:29:20 +08:00
XingfenD 217adf5914 test(frontend): e2e api/axe helpers 2026-09-15 14:13:58 +08:00
XingfenD ba28fe9037 test(frontend): tiny 3-page CBZ fixture for e2e 2026-09-15 14:11:24 +08:00
XingfenD b65ea93984 test(frontend): playwright e2e scaffolding (config + scripts) 2026-09-15 12:00:45 +08:00
XingfenD a3755bcfab test(frontend): sample component tests for button/dialog/theme-toggle (B2) 2026-09-15 11:54:56 +08:00
XingfenD 38957b4c2c fix(frontend): install @testing-library/dom (missing peer under legacy-peer-deps) 2026-09-15 11:50:44 +08:00
XingfenD ca419410b4 test(frontend): component testing infra (jsdom + testing-library + vitest projects) 2026-09-15 11:46:20 +08:00
XingfenD 147339083b chore(frontend): wire lint+format into check gate, changelog (B1 done) 2026-09-15 11:44:21 +08:00
XingfenD a427aa1c4a fix(frontend): resolve all eslint errors to zero (B1) 2026-09-15 11:39:21 +08:00
XingfenD 29167d3cea style(frontend): prettier baseline format (no logic change) 2026-09-15 11:27:54 +08:00
XingfenD 09a02fa0d7 chore(frontend): eslint flat config + prettier baseline config 2026-09-15 11:22:02 +08:00
XingfenD 24c5d42531 docs: frontend quality implementation plan (16 tasks, 4 batches) 2026-09-15 11:12:38 +08:00
XingfenD 42a39ceef8 docs: frontend engineering quality spec (subproject ④) 2026-09-15 10:59:43 +08:00
XingfenD b3a207a5d2 Merge remote-tracking branch 'origin/fix/backend-hardening'
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-15 00:42:26 +08:00
XingfenD 8305af9d5c docs: changelog + README backend structure for batch C (Task 29)
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
- CHANGELOG [Unreleased]: consolidated the duplicated Added/Changed/Fixed
  groups left by earlier batches into one group each (no entry dropped);
  added batch C entries — port-based restructure, media/upload domain
  packages, sweep moved to scanner ticker (B16), router contract test,
  portsfake unit-test layer. Upload-sweep wording no longer promises the
  old 24h opportunistic request-path behaviour.
- README.md / README_zh.md: new 'Backend structure' section documenting
  the port/fake layout (cmd/webui composition root, handlers as HTTP-only,
  internal/ports + portsfake, media/upload/store/scanner/bookfile
  responsibilities) and the two-tier testing approach (real PG+Redis
  integration vs fake-injected unit, route table pinned by contract test)

Gate: gofmt clean, go vet clean, go test -p 1 all pass (0 skip),
scripts/smoke.sh ALL SMOKE TESTS PASSED against a live webui on :18080
2026-09-14 23:37:36 +08:00
XingfenD 85c61d9f24 refactor: consolidate isUnique and path validation helpers (Task 28)
- seed.Admin: hand-rolled pgconn.PgError 23505 check → store.IsUniqueViolation
  (single predicate for unique violations across the codebase)
- books.absBookPath: local hasPrefixDir (filepath.Rel-based) removed in favor
  of bookfile.Contains, which also resolves symlinks — stricter escape check
- handlers isUnique alias + libraries.go local prefix check were already
  folded into ports.IsUniqueViolation / bookfile.Contains in Task 25;
  scanner.inside was folded in Task 24 — this commit closes the last two

Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
2026-09-14 23:26:16 +08:00
XingfenD cc1470f6e4 test: portsfake + router contract test + handler unit tests (Task 27)
- internal/ports/portsfake: hand-written in-memory fakes for all 9 ports
  (Users/Libraries/Books/Progress/Bookmarks/RateLimiter/Scanner/Media/Uploads);
  error semantics mirror the real store exactly — pgx.ErrNoRows for misses,
  store.ErrLastAdmin guard, and *pgconn.PgError{Code:23505} for unique
  violations (ports.IsUniqueViolation only accepts the PgError shape, so the
  fakes must produce it to exercise the 409 branch without touching prod code)
- Media/Uploads fakes are hook-programmable: one field per error branch, so a
  test can force e.g. CompleteErr=ErrIncomplete without stubbing the rest
- Scanner fake is mutex-guarded + WaitForScan: handler fires ScanLibraryByID
  in a goroutine, tests stay deterministic
- router_test.go: TestRouterContract pins all 27 routes — any route table
  change now fails the test explicitly
- handler unit tests (~30 cases, no PG/Redis): users CRUD branches (self-delete
  400, last-admin 400, dup 409, 204 ok), library reserved names (contract:
  code=bad_request message=reserved_name, per original impl), upload sentinel
  mapping (413/400/404 per branch)
- NewRouter takes pure port interfaces; main.go distributes *store.Store
  across the 5 store ports at the composition root

Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
2026-09-14 23:23:10 +08:00
XingfenD 7baadedeb4 refactor(handlers): consume port interfaces, wire media+upload assembly (Task 25+26)
- H now holds only small port interfaces (UserStore/LibraryStore/BookStore/
  ProgressStore/BookmarkStore/RateLimiter/Scanner/Media/UploadSessions);
  NewRouter is the composition root distributing *store.Store and *redispkg.R
- ports.Media gains EnsurePage; ChaptersOf returns ports.Chapter (media's
  local duplicate dropped); *media.M now provably satisfies ports.Media;
  ports.UploadSessions gains LibraryID for root validation before Complete
- content.go: duplicated page-index cache + cover self-heal + page extract
  logic removed in favor of media service — same redis keys, same contract;
  path traversal check stays in handler (403 semantics preserved)
- getLibrary/getLibRow merged into getLib(c, id); idParam helper dedupes
  :id parsing; isUnique replaced by ports.IsUniqueViolation (Task 28 partial)
- main.go assembles media + upload and passes upload.U as scanner Sweeper

Full gate green: gofmt, vet, go test -p 1 (real PG+Redis, 0 skip)
2026-09-14 22:56:15 +08:00
XingfenD b9b7022212 refactor(scanner): merge add/update into ingest, use shared utilities
- add/update consolidated into ingest(isNew) — persist branch differs,
  error handling and cover path fully shared (Task 24)
- zipIndex/cover use bookfile.OpenReaderAt instead of hand-rolled
  open+stat pairs
- root containment check uses bookfile.Contains (local inside removed)
- cover write goes through media.WriteAtomic (now exported, log-free —
  callers own context); media no longer logs inside the atomic helper
2026-09-14 22:12:30 +08:00
XingfenD fbd5cd243d feat(upload): extract upload subsystem + move sweep to scanner ticker (B16)
- internal/upload owns chunked-upload domain logic (fingerprint resume,
  part tmp+rename, assemble, session sweep) and the shared UniquePath
  helper used by both single-file and chunked completion paths
- handlers/uploads.go is now HTTP-only: bind params, call upload.U, map
  sentinel errors to the unchanged status/code/message contract
- B16: session sweep moved out of the UploadInit request path onto the
  scanner ticker via a Sweeper hook (upload.U satisfies it)
- unit tests for the package without PG/Redis; contract pinned by the
  existing handler integration tests (all green)
2026-09-14 22:00:25 +08:00
XingfenD 443f4acfa9 feat(media): Media service with EnsureCover, EnsurePage, PageIndex, ChaptersOf, writeAtomic
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
2026-09-14 19:50:34 +08:00
XingfenD 5ad277241c feat(bookfile): add Contains and OpenReaderAt utilities 2026-09-14 19:49:50 +08:00
XingfenD 18bd32e0f6 refactor(store): split into per-aggregate files, unexport pool, remove dead ListBookIDs 2026-09-14 19:49:18 +08:00
XingfenD 0c3b34c184 feat(ports): define consumer-side interfaces, sentinel errors, IsUniqueViolation 2026-09-14 19:47:27 +08:00
XingfenD f99f5a7878 docs: changelog for batch B (B1-B13 bug fixes); gofmt store.go 2026-09-14 19:46:16 +08:00
XingfenD 08b81fde60 fix(upload): retry on O_EXCL collision in single-file upload (B12) 2026-09-14 19:45:08 +08:00
XingfenD 8023a7ec5c fix(serve): check cover write errors, clean tmp only on failure path (B11) 2026-09-14 19:43:27 +08:00
XingfenD 0c5dfd4353 fix(library): reject reserved names (cache, .uploads) with 400 reserved_name (B8) 2026-09-14 19:42:52 +08:00
XingfenD 0e62d4aa18 fix(handlers): Upload io.Copy error → 500 not 413 (B6), Me distinguishes no-rows from DB errors (B7) 2026-09-14 19:42:25 +08:00
XingfenD b82a891c50 fix: upload part tmp+rename (B4), transactional last-admin DeleteUser (B5), RowsAffected order (B13) 2026-09-14 19:42:11 +08:00
XingfenD 8fbc58eaaa fix(redis,scanner): atomic IncrWindow Lua, lock rand/ctx fixes, ScanLock renewal, single-flight, error logging (B1,B2,B3,B9,B10,B11) 2026-09-14 19:40:53 +08:00
XingfenD 2c2f6d3116 chore: gofmt content_test.go 2026-09-14 19:38:33 +08:00
XingfenD 26111e73af docs: changelog + README updates for batch A (migrations, CI, B14/B15/B17) 2026-09-14 19:38:16 +08:00
XingfenD b63d50e04b fix(smoke): drop ignored root_path field, align with API contract (B17) 2026-09-14 19:37:42 +08:00
XingfenD f26aac103b fix(config): validate DATABASE_URL required and parseable, log redis disabled (B15) 2026-09-14 19:37:26 +08:00
XingfenD f0437139f3 fix(main): channel-based serve error instead of log.Fatalf in goroutine (B14) 2026-09-14 19:36:57 +08:00
XingfenD 161128cac0 ci: add GitHub Actions workflow (Gitea Actions compatible) 2026-09-14 19:36:31 +08:00
XingfenD 6d3d23c3f9 feat(db): ordered migration system with advisory lock and baseline detection 2026-09-14 19:36:21 +08:00
XingfenD c9b32db592 docs(plan): backend hardening implementation plan — 29 tasks across 3 batches 2026-09-14 19:33:30 +08:00
XingfenD 942a9952ca docs(spec): clarify batch attribution for B8/B9-2 (depend on batch-C packages) 2026-09-14 19:14:45 +08:00
XingfenDandCommandCodeBot 2cf842af9e docs(spec): backend hardening design — migrations, 17 bug fixes, ports/internal restructure, CI
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
2026-09-14 16:55:44 +08:00
XingfenD a22b44be06 Merge branch 'fix/cbz-end-chapter-jump' 2026-09-08 22:45:20 +08:00
XingfenD 2e43cb2423 fix(web): stop CBZ reader snapping/bouncing at chapter end on mixed-orientation books 2026-09-08 22:42:05 +08:00
XingfenD 90b39d4f97 Merge branch 'feat/ui-redesign' 2026-09-08 21:48:41 +08:00
XingfenD fd227c1f00 feat(web): table-style 库管理, admin entries under avatar 系统管理, shelf-view upload button 2026-09-08 21:45:03 +08:00
142 changed files with 14722 additions and 2182 deletions
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [master, 'fix/**', 'feat/**']
pull_request:
branches: [master]
jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: booklib
POSTGRES_PASSWORD: booklib
POSTGRES_DB: booklib
ports: ['5432:5432']
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 3s
--health-retries 10
redis:
image: redis:7
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 3s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
- name: Format check
run: |
cd backend
OUT=$(gofmt -l .)
if [ -n "$OUT" ]; then echo "gofmt violations:"; echo "$OUT"; exit 1; fi
- name: Vet
run: cd backend && go vet ./...
- name: Test
run: cd backend && go test -p 1 -count=1 ./...
env:
DATABASE_URL: postgres://booklib:booklib@localhost:5432/booklib?sslmode=disable
REDIS_URL: redis://localhost:6379/0
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: cd frontend && npm ci
- run: cd frontend && npm run check
+41
View File
@@ -0,0 +1,41 @@
name: e2e
on: workflow_dispatch
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Render configs
working-directory: deploy
run: ./prepare.sh
- name: Start dev stack
working-directory: deploy
env:
JWT_SECRET: ci-e2e-only-secret
ADMIN_USER: admin
ADMIN_PASSWORD: ci-e2e-password
run: docker compose -f docker-compose.dev.yml up -d --build
- name: Wait for web
run: |
for i in $(seq 1 90); do
if curl -fsS http://localhost:5173/ >/dev/null 2>&1; then exit 0; fi
sleep 5
done
echo "web not up in 450s"; docker compose -f deploy/docker-compose.dev.yml logs --tail 50; exit 1
- run: cd frontend && npm ci
- run: cd frontend && npx playwright install --with-deps chromium
- name: Run e2e
working-directory: frontend
env:
E2E_ADMIN_USER: admin
E2E_ADMIN_PASSWORD: ci-e2e-password
run: npx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: frontend/playwright-report
+3
View File
@@ -3,3 +3,6 @@
tasks/
.playwright*/
frontend/tsconfig.tsbuildinfo
playwright-report/
test-results/
dist-stats/
+1
View File
@@ -28,6 +28,7 @@ Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node
- Talks to the backend only over the HTTP APIs in `backend/cmd/webui/api` — no direct infra access (DB, redis) from the browser app.
- Build output (`dist/`) is disposable and git-ignored; only `src`/`public` are committed.
- Dev runs from the source mount with `node_modules` provided by the image — install new deps inside the container and commit the lockfile.
- TypeScript runs side-by-side (`frontend/package.json`): `typescript` = TS 6.0.2 JS API for tooling (typescript-eslint doesn't support TS 7 yet), `@typescript/native` = native 7.0.2 providing `tsc`. Do not re-alias `typescript` to 7.x until typescript-eslint ships TS 7 support. Plain `npm install` needs `--legacy-peer-deps` (also pinned in `frontend/.npmrc`); `npm ci` is unaffected.
### deploy
- Only config templates/examples are tracked by git; rendered `*.conf` and `logs/` are git-ignored.
+17 -15
View File
@@ -7,14 +7,16 @@ import (
"booklib/cmd/webui/handlers"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
"booklib/internal/ports"
)
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
// NewRouter 只依赖 port 接口:main.go 传具体实现(*store.Store 满足 5 个 store
// 接口,*redispkg.R 满足 RateLimiter),测试传 portsfake。
func NewRouter(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore,
progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter,
sc ports.Scanner, med ports.Media, up ports.UploadSessions) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
h := handlers.New(cfg, st, rdb, sc)
h := handlers.New(cfg, users, libs, books, progress, bookmarks, rl, sc, med, up)
r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e)
@@ -27,17 +29,17 @@ func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner
p := g.Group("", h.AuthMw())
p.GET("/auth/me", h.Me)
users := p.Group("/users", h.AdminOnly())
users.GET("", h.ListUsers)
users.POST("", h.CreateUser)
users.DELETE("/:id", h.DeleteUser)
usersGrp := p.Group("/users", h.AdminOnly())
usersGrp.GET("", h.ListUsers)
usersGrp.POST("", h.CreateUser)
usersGrp.DELETE("/:id", h.DeleteUser)
libs := p.Group("/libraries")
libs.GET("", h.ListLibraries)
libs.POST("", h.AdminOnly(), h.CreateLibrary)
libs.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
libs.POST("/:id/upload", h.AdminOnly(), h.Upload)
libs.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit)
libsGrp := p.Group("/libraries")
libsGrp.GET("", h.ListLibraries)
libsGrp.POST("", h.AdminOnly(), h.CreateLibrary)
libsGrp.POST("/:id/scan", h.AdminOnly(), h.ScanLibrary)
libsGrp.POST("/:id/upload", h.AdminOnly(), h.Upload)
libsGrp.POST("/:id/upload/init", h.AdminOnly(), h.UploadInit)
uploads := p.Group("/uploads", h.AdminOnly())
uploads.GET("/:uid", h.UploadStatus)
+54 -2
View File
@@ -7,7 +7,6 @@ import (
"time"
"booklib/internal/config"
"booklib/internal/redispkg"
)
func testCfg() *config.Config {
@@ -16,7 +15,7 @@ func testCfg() *config.Config {
}
func TestHealthz(t *testing.T) {
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -24,3 +23,56 @@ func TestHealthz(t *testing.T) {
t.Fatalf("healthz = %d, want 200", w.Code)
}
}
// TestRouterContract 钉死全量路由表(Task 27):增删改任何路由都会使此测试失败,
// 必须显式更新——路由即对外契约。
func TestRouterContract(t *testing.T) {
r := NewRouter(testCfg(), nil, nil, nil, nil, nil, nil, nil, nil, nil)
want := map[string]bool{
"GET /api/healthz": true,
"POST /api/auth/login": true,
"GET /api/auth/me": true,
"GET /api/users": true,
"POST /api/users": true,
"DELETE /api/users/:id": true,
"GET /api/libraries": true,
"POST /api/libraries": true,
"POST /api/libraries/:id/scan": true,
"POST /api/libraries/:id/upload": true,
"POST /api/libraries/:id/upload/init": true,
"GET /api/uploads/:uid": true,
"POST /api/uploads/:uid/complete": true,
"PUT /api/uploads/:uid/parts/:index": true,
"GET /api/books": true,
"GET /api/books/:id": true,
"DELETE /api/books/:id": true,
"GET /api/books/:id/cover": true,
"GET /api/books/:id/file": true,
"GET /api/books/:id/pages": true,
"GET /api/books/:id/pages/:n": true,
"PUT /api/books/:id/progress": true,
"GET /api/progress": true,
"GET /api/books/:id/bookmarks": true,
"POST /api/books/:id/bookmarks": true,
"PATCH /api/bookmarks/:id": true,
"DELETE /api/bookmarks/:id": true,
}
got := map[string]bool{}
for _, rt := range r.Routes() {
key := rt.Method + " " + rt.Path
if got[key] {
t.Errorf("duplicate route %s", key)
}
got[key] = true
}
for k := range want {
if !got[k] {
t.Errorf("missing route %s", k)
}
}
for k := range got {
if !want[k] {
t.Errorf("unexpected route %s", k)
}
}
}
+9 -4
View File
@@ -20,11 +20,11 @@ func (h *H) Login(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "username and password required")
return
}
if n := h.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
if n := h.rl.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
err(c, http.StatusTooManyRequests, "rate_limited", "too many login attempts")
return
}
u, qerr := h.st.GetUserByName(c, req.Username)
u, qerr := h.users.GetUserByName(c, req.Username)
if qerr != nil {
if !errors.Is(qerr, pgx.ErrNoRows) {
dbErr(c, qerr)
@@ -48,9 +48,14 @@ func (h *H) Login(c *gin.Context) {
}
func (h *H) Me(c *gin.Context) {
u, qerr := h.st.GetUserByID(c, uid(c))
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503.
u, qerr := h.users.GetUserByID(c, uid(c))
if qerr != nil {
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
if errors.Is(qerr, pgx.ErrNoRows) {
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
return
}
dbErr(c, qerr)
return
}
c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role})
+6 -2
View File
@@ -18,9 +18,11 @@ import (
"booklib/internal/auth"
"booklib/internal/config"
"booklib/internal/db"
"booklib/internal/media"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
"booklib/internal/upload"
)
func testCfg() *config.Config {
@@ -57,8 +59,10 @@ func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, strin
cfg.BooksDir = booksDir
cfg.CacheDir = t.TempDir()
rdb := redispkg.New(os.Getenv("REDIS_URL"))
sc := scanner.New(st, cfg, rdb)
r := api.NewRouter(cfg, st, rdb, sc)
med := media.New(cfg, rdb)
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
sc := scanner.New(st, cfg, rdb, up)
r := api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up)
if u := os.Getenv("REDIS_URL"); u != "" { // 测试卫生: 共享 redis 上重置登录限流桶, 防跨测试累计 429
if opt, e := redis.ParseURL(u); e == nil {
rc := redis.NewClient(opt)
+4 -4
View File
@@ -27,7 +27,7 @@ func (h *H) ListBookmarks(c *gin.Context) {
if !ok {
return
}
rows, e := h.st.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
rows, e := h.bookmarks.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
if e != nil {
dbErr(c, e)
return
@@ -65,7 +65,7 @@ func (h *H) CreateBookmark(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return
}
id, e := h.st.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note)
id, e := h.bookmarks.InsertBookmark(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent, req.Note)
if e != nil {
dbErr(c, e)
return
@@ -101,7 +101,7 @@ func (h *H) PatchBookmark(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return
}
updated, e := h.st.UpdateBookmarkNote(c, uid(c), id, req.Note)
updated, e := h.bookmarks.UpdateBookmarkNote(c, uid(c), id, req.Note)
if e != nil {
dbErr(c, e)
return
@@ -118,7 +118,7 @@ func (h *H) DeleteBookmark(c *gin.Context) {
if !ok {
return
}
deleted, e := h.st.DeleteBookmark(c, uid(c), id)
deleted, e := h.bookmarks.DeleteBookmark(c, uid(c), id)
if e != nil {
dbErr(c, e)
return
+11 -30
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -18,7 +17,7 @@ import (
)
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
b, e := h.st.GetBook(c, id)
b, e := h.books.GetBook(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such book")
@@ -31,41 +30,23 @@ func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
}
func (h *H) 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")
id, ok := idParam(c)
if !ok {
return store.Book{}, false
}
return h.getBookRow(c, id)
}
func (h *H) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
l, e := h.st.GetLibrary(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such library")
return store.Library{}, false
}
dbErr(c, e)
return store.Library{}, false
}
return l, true
}
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)
// absBookPath: books.path 永远相对且不含 ..;拼接后二次前缀校验(纵深防御)。
// bookfile.Contains 带 EvalSymlinks,比裸 filepath.Rel 更能拦住软链逃逸。
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) {
if filepath.Clean(abs) != abs || !bookfile.Contains(root, abs) {
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{
@@ -89,7 +70,7 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
func (h *H) ListBooks(c *gin.Context) {
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
views, e := h.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
views, e := h.books.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
if e != nil {
dbErr(c, e)
return
@@ -106,12 +87,12 @@ func (h *H) GetBook(c *gin.Context) {
if !ok {
return
}
p, e := h.st.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
p, e := h.progress.GetProgress(c, uid(c), b.LibraryID, b.Path) // ErrNoRows → 零值 percent
if e != nil && !errors.Is(e, pgx.ErrNoRows) {
dbErr(c, e)
return
}
lib, e := h.st.GetLibrary(c, b.LibraryID)
lib, e := h.libs.GetLibrary(c, b.LibraryID)
if e != nil && !errors.Is(e, pgx.ErrNoRows) { // 库被并发删则留空 library 名,书仍可见
dbErr(c, e)
return
@@ -124,7 +105,7 @@ func (h *H) DeleteBook(c *gin.Context) {
if !ok {
return
}
lib, ok := h.getLibRow(c, b.LibraryID)
lib, ok := h.getLib(c, b.LibraryID)
if !ok {
return
}
@@ -144,7 +125,7 @@ func (h *H) DeleteBook(c *gin.Context) {
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
if e := h.st.DeleteBook(c, b.ID); e != nil {
if e := h.books.DeleteBook(c, b.ID); e != nil {
dbErr(c, e)
return
}
+68 -144
View File
@@ -1,14 +1,13 @@
package handlers
import (
"fmt"
"errors"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -19,7 +18,7 @@ import (
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 (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := h.getLibRow(c, b.LibraryID)
lib, ok := h.getLib(c, b.LibraryID)
if !ok {
return "", false
}
@@ -30,6 +29,26 @@ func (h *H) immutable(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
// checkPath 纵深防御:path 越界 → 403,与原契约一致。
func checkPath(c *gin.Context, root string, b store.Book) (string, bool) {
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return "", false
}
return abs, true
}
// mapContentErr 把 media/bookfile 的文件级失败映射回原契约状态码:
// 文件不在盘上 → 404,其余(坏包等)由调用方决定 422/500。
func mapContentErr(c *gin.Context, e error) bool {
if errors.Is(e, fs.ErrNotExist) {
err(c, http.StatusNotFound, "not_found", "file missing on disk")
return true
}
return false
}
func (h *H) ServeCover(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
@@ -47,32 +66,30 @@ func (h *H) ServeCover(c *gin.Context) {
}
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := h.bookRoot(c, b); ok {
if f, size, ok := h.openBook(c, b, root); ok {
defer f.Close()
var img []byte
var ext string
var e error
if b.Format == "cbz" {
img, ext, e = bookfile.CBZCover(f, size)
} else {
img, ext, e = bookfile.EPUBCover(f, size)
}
if e == nil {
dst := filepath.Join(dir, "cover"+ext)
if e := os.MkdirAll(dir, 0o755); e == nil {
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano()) // 并发幂等:唯一 tmp + rename 原子
if e := os.WriteFile(tmp, img, 0o644); e == nil {
if e := os.Rename(tmp, dst); e == nil {
http.ServeFile(c.Writer, c.Request, dst)
if _, ok := checkPath(c, root, b); ok {
e := h.med.EnsureCover(c, b.ID, b.Format, b.FileSize, b.ModTS, root, b.Path)
switch {
case e == nil:
if entries, re := os.ReadDir(dir); re == nil {
for _, en := range entries {
if !strings.Contains(en.Name(), ".tmp") {
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
return
}
}
os.Remove(tmp)
}
case mapContentErr(c, e):
return // 404 已回复
}
// 抽取失败(坏包)→ 落到占位 SVG,与原契约一致
} else {
return // 403 已回复
}
} else {
return // 404/503 已回复
}
}
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
if c.Writer.Written() {
return
}
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
@@ -87,55 +104,32 @@ func (h *H) ServeFile(c *gin.Context) {
if !ok {
return
}
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
abs, ok := checkPath(c, root, b)
if !ok {
return
}
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
if _, e := os.Stat(abs); e != nil {
err(c, http.StatusNotFound, "not_found", "file missing on disk")
return
}
c.Header("ETag", `"`+h.med.CacheBuster(b.FileSize, b.ModTS)+`"`)
c.Header("Cache-Control", "private, must-revalidate")
http.ServeFile(c.Writer, c.Request, abs)
}
func (h *H) 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
// pageIndex 走 media(redis 缓存 + 索引提取);路径校验仍在 handler,保住 403 契约。
func (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, bool) {
if _, ok := checkPath(c, root, b); !ok {
return nil, 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 (h *H) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
hash := bookfile.Hash(b.FileSize, b.ModTS)
key := fmt.Sprintf("pagesidx2:%d:%s", b.ID, hash) // 前缀换代 = 索引逻辑变更时一次性作废旧缓存
if v, ok := h.rdb.Get(c, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, size, ok := h.openBook(c, b, root)
if !ok {
return nil, os.ErrNotExist
}
defer f.Close()
idx, e := bookfile.PageIndex(f, size)
idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path)
if e != nil {
return nil, e
if !mapContentErr(c, e) {
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
}
return nil, false
}
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
h.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
return idx, true
}
func (h *H) PagesCount(c *gin.Context) {
@@ -151,56 +145,11 @@ func (h *H) PagesCount(c *gin.Context) {
if !ok {
return
}
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
idx, ok := h.pageIndex(c, b, root)
if !ok {
return
}
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": chaptersOf(idx)})
}
type cbzChapter struct {
Title string `json:"title"`
Start int `json:"start"`
}
// chaptersOf 页索引已自然序排列,按父目录分组:每个新目录开一章,标题取目录名;扁平包或只有一组返回 nil
func chaptersOf(idx []string) []cbzChapter {
type grp struct {
dir string
start int
}
var grps []grp
last := "\x00"
for i, n := range idx {
d := path.Dir(n)
if d == last {
continue
}
last = d
if d == "." { // 根目录散页不开章
continue
}
grps = append(grps, grp{d, i})
}
if len(grps) < 2 {
return nil
}
titles := make(map[string]int)
out := make([]cbzChapter, len(grps))
for i, g := range grps {
out[i] = cbzChapter{Title: path.Base(g.dir), Start: g.start}
titles[out[i].Title]++
}
for i, g := range grps { // 同名目录(不同父级)撞车 → 用全路径消歧
if titles[out[i].Title] > 1 {
out[i].Title = g.dir
}
}
return out
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(idx)})
}
func (h *H) Page(c *gin.Context) {
@@ -221,47 +170,22 @@ func (h *H) Page(c *gin.Context) {
if !ok {
return
}
idx, e := h.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
idx, ok := h.pageIndex(c, b, root)
if !ok {
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(h.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 → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
f, size, ok := h.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
}
if e := os.MkdirAll(dir, 0o755); e != nil {
err(c, http.StatusInternalServerError, "internal", "cache dir")
return
}
tmp := fmt.Sprintf("%s.tmp-%d", dst, time.Now().UnixNano())
if e := os.WriteFile(tmp, data, 0o644); e != nil {
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "write cache")
return
}
if e := os.Rename(tmp, dst); e != nil {
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename cache")
// miss → 解压落盘(media 内部唯一 tmp 名 + rename 原子,并发重做同页幂等)
dst, e := h.med.EnsurePage(c, b.ID, b.FileSize, b.ModTS, root, b.Path, n, idx)
if e != nil {
if mapContentErr(c, e) {
return
}
err(c, http.StatusInternalServerError, "internal", "extract page")
return
}
h.immutable(c)
http.ServeFile(c.Writer, c.Request, dst)
+3 -3
View File
@@ -126,9 +126,9 @@ func TestPagesChaptersAndNoImageZip(t *testing.T) {
tok := adminToken(t, h)
lib, root := newLibrary(t, st, h, tok, booksDir, "ch")
zipTo(t, filepath.Join(root, "show.cbz"), map[string]string{
"第2季/第2話/0001.jpg": "IMG2",
"第2季/第1話/0001.jpg": "IMG1a",
"第2季/第1話/._0001.jpg": "junk",
"第2季/第2話/0001.jpg": "IMG2",
"第2季/第1話/0001.jpg": "IMG1a",
"第2季/第1話/._0001.jpg": "junk",
"__MACOSX/第2季/._0001.jpg": "junk",
})
zipTo(t, filepath.Join(root, "videos.zip"), map[string]string{"ep/01.mkv": "x"})
+29 -9
View File
@@ -6,6 +6,7 @@ import (
"log"
"net"
"net/http"
"strconv"
"strings"
"syscall"
@@ -15,26 +16,45 @@ import (
"booklib/internal/auth"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
"booklib/internal/ports"
)
// H 只依赖 ports 里的小口径接口(Task 25):具体实现由 main.go 装配,
// 测试可注入手写 fake(portsfake),无需 PG/Redis。
type H struct {
cfg *config.Config
st *store.Store
rdb *redispkg.R
sc *scanner.Scanner
cfg *config.Config
users ports.UserStore
libs ports.LibraryStore
books ports.BookStore
progress ports.ProgressStore
bookmarks ports.BookmarkStore
rl ports.RateLimiter
sc ports.Scanner
med ports.Media
up ports.UploadSessions
}
func New(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *H {
return &H{cfg: cfg, st: st, rdb: rdb, sc: sc}
func New(cfg *config.Config, users ports.UserStore, libs ports.LibraryStore, books ports.BookStore,
progress ports.ProgressStore, bookmarks ports.BookmarkStore, rl ports.RateLimiter,
sc ports.Scanner, med ports.Media, up ports.UploadSessions) *H {
return &H{cfg: cfg, users: users, libs: libs, books: books, progress: progress,
bookmarks: bookmarks, rl: rl, sc: sc, med: med, up: up}
}
func err(c *gin.Context, status int, code, msg string) {
c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": msg}})
}
// idParam 解析 :id 路径参数,失败已回复 400。
func idParam(c *gin.Context) (int64, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return 0, false
}
return id, true
}
// dbErr 统一处理 store 层失败:记日志;连接类错误 503(Service Unavailable),其余 500
// 注:brief 里的 pgxpool.ErrClosedPool 在 pgx v5 不存在,实际由 puddle 原样透出,用它替代;
// PG 停机时池内连接先收到 SQLSTATE 57P01(administrator shutdown),故把 08xx/57Pxx 也归为 503
+69 -59
View File
@@ -15,6 +15,8 @@ import (
"github.com/jackc/pgx/v5"
"booklib/internal/bookfile"
"booklib/internal/media"
"booklib/internal/ports"
"booklib/internal/store"
)
@@ -22,7 +24,7 @@ import (
func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
root := filepath.Clean(lib.RootPath)
books := filepath.Clean(h.cfg.BooksDir)
if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
if !filepath.IsAbs(root) || !bookfile.Contains(books, root) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false
}
@@ -33,8 +35,22 @@ func (h *H) libRoot(c *gin.Context, lib store.Library) (string, bool) {
return root, true
}
// getLib 查库行,404/503/500 已回复(原 getLibrary/getLibRow 合一,Task 25)
func (h *H) getLib(c *gin.Context, id int64) (store.Library, bool) {
l, e := h.libs.GetLibrary(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such library")
return store.Library{}, false
}
dbErr(c, e)
return store.Library{}, false
}
return l, true
}
func (h *H) ListLibraries(c *gin.Context) {
libs, e := h.st.ListLibraries(c)
libs, e := h.libs.ListLibraries(c)
if e != nil {
dbErr(c, e)
return
@@ -61,10 +77,15 @@ func (h *H) CreateLibrary(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "bad name")
return
}
// B8: reject reserved names that conflict with system directories.
if media.IsReservedName(safe) {
err(c, http.StatusBadRequest, "bad_request", "reserved_name")
return
}
root := filepath.Join(filepath.Clean(h.cfg.BooksDir), safe)
id, e := h.st.CreateLibrary(c, req.Name, root)
id, e := h.libs.CreateLibrary(c, req.Name, root)
if e != nil {
if isUnique(e) {
if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "name taken")
return
}
@@ -74,26 +95,12 @@ func (h *H) CreateLibrary(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
}
func (h *H) 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 := h.st.GetLibrary(c, id)
if e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such library")
return store.Library{}, false
}
dbErr(c, e)
return store.Library{}, false
}
return lib, true
}
func (h *H) ScanLibrary(c *gin.Context) {
lib, ok := h.getLibrary(c)
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
@@ -105,7 +112,11 @@ func (h *H) ScanLibrary(c *gin.Context) {
}
func (h *H) Upload(c *gin.Context) {
lib, ok := h.getLibrary(c)
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
@@ -129,27 +140,51 @@ func (h *H) Upload(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
dst, e := h.uniquePath(root, name)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
// B12: retry on O_EXCL collision — concurrent uploads with the same name
// can both get the same candidate from UniquePath (stat-then-create race).
var dst, tmp string
var out *os.File
for attempt := 0; attempt < 5; attempt++ {
var e error
dst, e = h.up.UniquePath(root, name)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
return
}
tmp = dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36)
out, e = os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if e == nil {
break
}
if !os.IsExist(e) {
err(c, http.StatusInternalServerError, "internal", "create tmp")
return
}
// O_EXCL collision — retry with fresh UniquePath.
}
if out == nil {
err(c, http.StatusConflict, "conflict", "too many concurrent uploads with same name")
return
}
src, e := fh.Open()
if e != nil {
out.Close()
os.Remove(tmp)
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
}
// B6: only MaxBytesError returns 413; other io.Copy failures (disk full,
// connection drop) return 500.
if _, e := io.Copy(out, src); e != nil {
out.Close()
os.Remove(tmp)
err(c, http.StatusRequestEntityTooLarge, "too_large", "upload failed")
var mbe *http.MaxBytesError
if errors.As(e, &mbe) {
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit")
return
}
err(c, http.StatusInternalServerError, "internal", "upload failed")
return
}
out.Close()
@@ -160,28 +195,3 @@ func (h *H) Upload(c *gin.Context) {
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
}
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
func (h *H) 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
}
}
}
@@ -0,0 +1,232 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"booklib/internal/ports"
"booklib/internal/upload"
)
// ---------- libraries (Task 27, portsfake) ----------
func TestUnit_ListLibraries(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "comics")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/libraries", atok, nil)
httpOK(t, w, 200, "list libraries")
var libs []map[string]any
if err := jsonUnmarshal(w, &libs); err != nil {
t.Fatal(err)
}
if len(libs) != 1 || libs[0]["name"] != "comics" {
t.Fatalf("unexpected libs %v", libs)
}
}
func TestUnit_CreateLibrary_ReservedName(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
// 保留名清单以 media/reserved.go 为准(B8);大小写不敏感。
// 契约(与重构前一致): code="bad_request", message="reserved_name"
for _, name := range []string{"cache", ".uploads", ".trash", "CACHE", "Cache"} {
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
if w.Code != 400 || errCode(t, w) != "bad_request" || !strings.Contains(w.Body.String(), "reserved_name") {
t.Fatalf("name=%q want 400/bad_request/reserved_name got %d %s", name, w.Code, w.Body.String())
}
}
}
func TestUnit_CreateLibrary_BadName(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
for _, name := range []string{"", "..", "///"} {
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": name})
httpOK(t, w, 400, "bad name "+name)
}
}
func TestUnit_CreateLibrary_Duplicate(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "dup")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "dup"})
httpOK(t, w, 409, "duplicate library")
if code := errCode(t, w); code != "exists" {
t.Fatalf("error code want exists got %q", code)
}
}
func TestUnit_CreateLibrary_OK(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries", atok, map[string]string{"Name": "new lib"})
httpOK(t, w, 201, "create library")
body := jsonBody(t, w)
if body["name"] != "new lib" {
t.Fatalf("unexpected body %v", body)
}
}
func TestUnit_ScanLibrary_NotFound(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/999/scan", atok, nil)
httpOK(t, w, 404, "scan missing library")
}
func TestUnit_ScanLibrary_Accepted(t *testing.T) {
e := newTestEnv(t)
libID, _ := e.seedLib(t, "scannable")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", atok, nil)
httpOK(t, w, 202, "scan accepted")
if !e.sc.WaitForScan(1, time.Second) {
t.Fatalf("ScanLibraryByID never called; seen=%v", e.sc.Seen())
}
if seen := e.sc.Seen(); seen[0] != libID {
t.Fatalf("scanned lib %d want %d", seen[0], libID)
}
}
func TestUnit_ScanLibrary_MemberForbidden(t *testing.T) {
e := newTestEnv(t)
libID, _ := e.seedLib(t, "nope")
mtok := e.token(t, "member", 2)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(libID, 10)+"/scan", mtok, nil)
httpOK(t, w, 403, "member scan")
}
// ---------- uploads: sentinel → status mapping ----------
func TestUnit_UploadInit_MapsSentinels(t *testing.T) {
libIDPath := func(e *testEnv) string {
id, _ := e.seedLib(t, "up")
return "/api/libraries/" + strconv.FormatInt(id, 10) + "/upload/init"
}
cases := []struct {
name string
initErr error
wantCode int
wantErr string
}{
{"too large", ports.ErrTooLarge, 413, "too_large"},
{"bad name", upload.ErrBadName, 400, "bad_request"},
{"bad format", upload.ErrBadFormat, 400, "bad_format"},
{"bad size", upload.ErrBadSize, 400, "bad_request"},
{"bad chunk", upload.ErrBadChunk, 400, "bad_request"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newTestEnv(t)
e.up.InitErr = tc.initErr
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, libIDPath(e), atok,
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
httpOK(t, w, tc.wantCode, "init "+tc.name)
if code := errCode(t, w); code != tc.wantErr {
t.Fatalf("error code want %q got %q", tc.wantErr, code)
}
})
}
}
func TestUnit_UploadInit_OK(t *testing.T) {
e := newTestEnv(t)
id, _ := e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok,
map[string]any{"name": "b.cbz", "size": 10, "chunkSize": 4})
httpOK(t, w, 200, "init ok")
body := jsonBody(t, w)
if body["uploadId"] != e.up.UID {
t.Fatalf("uploadId want %q got %v", e.up.UID, body["uploadId"])
}
}
func TestUnit_UploadInit_BadBody(t *testing.T) {
e := newTestEnv(t)
id, _ := e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/libraries/"+strconv.FormatInt(id, 10)+"/upload/init", atok, nil)
httpOK(t, w, 400, "init without json body")
}
func TestUnit_UploadStatus_NotFound(t *testing.T) {
e := newTestEnv(t)
e.up.StatusErr = ports.ErrNotFound
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
httpOK(t, w, 404, "status missing upload")
if code := errCode(t, w); code != "not_found" {
t.Fatalf("error code want not_found got %q", code)
}
}
func TestUnit_UploadStatus_OK(t *testing.T) {
e := newTestEnv(t)
e.up.Received = []int64{0, 2}
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/uploads/"+e.up.UID, atok, nil)
httpOK(t, w, 200, "status ok")
var body struct {
Received []int64 `json:"received"`
}
if err := jsonUnmarshal(w, &body); err != nil {
t.Fatal(err)
}
if len(body.Received) != 2 || body.Received[1] != 2 {
t.Fatalf("received want [0 2] got %v", body.Received)
}
}
func TestUnit_UploadPart_BadIndex(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/abc", atok, nil)
httpOK(t, w, 400, "part bad index")
}
func TestUnit_UploadPart_TooBig(t *testing.T) {
e := newTestEnv(t)
e.up.PutErr = upload.ErrPartTooBig
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPut, "/api/uploads/"+e.up.UID+"/parts/0", atok, nil)
httpOK(t, w, 413, "part too big")
}
func TestUnit_UploadComplete_Incomplete(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "up") // fake LibraryID=1 与 seed 的第一条对齐
e.up.CompleteErr = ports.ErrIncomplete
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
httpOK(t, w, 400, "complete incomplete")
}
func TestUnit_UploadComplete_OK(t *testing.T) {
e := newTestEnv(t)
e.seedLib(t, "up")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
httpOK(t, w, 202, "complete ok")
body := jsonBody(t, w)
if body["path"] != e.up.RelPath {
t.Fatalf("path want %q got %v", e.up.RelPath, body["path"])
}
}
func TestUnit_UploadComplete_LibGone(t *testing.T) {
e := newTestEnv(t) // LibraryID=1 但库里没有 id=1 → GetLibrary ErrNoRows → 404? dbErr → 500
e.up.LibID = 42
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/uploads/"+e.up.UID+"/complete", atok, nil)
// dbErr 把 pgx.ErrNoRows 归为 500(非连接类),契约与真库一致
if w.Code != 500 {
t.Fatalf("lib gone want 500 got %d body=%s", w.Code, w.Body.String())
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ func (h *H) PutProgress(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
return
}
if e := h.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
if e := h.progress.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
dbErr(c, e)
return
}
@@ -40,7 +40,7 @@ func (h *H) PutProgress(c *gin.Context) {
}
func (h *H) ListProgress(c *gin.Context) {
rows, e := h.st.ListProgress(c, uid(c))
rows, e := h.progress.ListProgress(c, uid(c))
if e != nil {
dbErr(c, e)
return
@@ -0,0 +1,142 @@
package handlers_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"booklib/cmd/webui/api"
"booklib/internal/auth"
"booklib/internal/config"
"booklib/internal/ports/portsfake"
)
// testEnv 是纯 fake 装配(Task 27):无 PG/Redis。请求走 api.NewRouter 组装的
// 真实 engine,因此认证/鉴权中间件、路由匹配与 gin 的 header 落盘行为都被覆盖;
// 集成测试(auth_test.go 的 setupAPI)另走真库,两层互补。
type testEnv struct {
cfg *config.Config
router *gin.Engine
users *portsfake.Users
libs *portsfake.Libraries
books *portsfake.Books
progress *portsfake.Progress
bookmarks *portsfake.Bookmarks
rl *portsfake.RateLimiter
sc *portsfake.Scanner
med *portsfake.Media
up *portsfake.Uploads
booksDir string
}
func newTestEnv(t *testing.T) *testEnv {
t.Helper()
gin.SetMode(gin.TestMode)
resolved, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
cfg := &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), UploadMaxMB: 1,
ScanInterval: time.Minute, BooksDir: resolved, CacheDir: t.TempDir(),
TrustedProxies: []string{"172.16.0.0/12"}}
users := portsfake.NewUsers()
libs := portsfake.NewLibraries()
books := portsfake.NewBooks()
progress := portsfake.NewProgress(libs, books)
e := &testEnv{
cfg: cfg, users: users, libs: libs, books: books,
progress: progress, bookmarks: portsfake.NewBookmarks(),
rl: portsfake.NewRateLimiter(), sc: portsfake.NewScanner(),
med: portsfake.NewMedia(), up: portsfake.NewUploads(),
booksDir: resolved,
}
e.router = api.NewRouter(cfg, users, libs, books, progress, e.bookmarks,
e.rl, e.sc, e.med, e.up)
return e
}
// token 签发一个带角色的 JWT(uid 固定 1,与 fake 里 seed 的用户对应)。
func (e *testEnv) token(t *testing.T, role string, uid int64) string {
t.Helper()
tok, err := auth.Sign(e.cfg.JWTSecret, uid, role)
if err != nil {
t.Fatal(err)
}
return tok
}
// do 以 Bearer token 走完整 engine;body 非 nil 时按 JSON 发送。
func (e *testEnv) do(t *testing.T, method, path, tok string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
reader = bytes.NewReader(b)
} else {
reader = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, reader)
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
e.router.ServeHTTP(w, req)
return w
}
// jsonBody 解析响应体为 map。
func jsonBody(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal %q: %v", w.Body.String(), err)
}
return out
}
// jsonUnmarshal 解析响应体到任意目标(slice/struct)。
func jsonUnmarshal(w *httptest.ResponseRecorder, dst any) error {
return json.Unmarshal(w.Body.Bytes(), dst)
}
// errCode 取 error.code 字段(错误响应契约的核心)。
func errCode(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
body := jsonBody(t, w)
e, _ := body["error"].(map[string]any)
if e == nil {
t.Fatalf("no error object in %q", w.Body.String())
}
return fmt.Sprint(e["code"])
}
// seedLib 建一个 root 落在 BooksDir 内的库(fake 行 + 真实目录),返回 id 与 root。
func (e *testEnv) seedLib(t *testing.T, name string) (int64, string) {
t.Helper()
root := filepath.Join(e.booksDir, name)
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
return e.libs.Seed(name, root), root
}
// httpOK 断言状态码,失败时带上响应体便于定位。
func httpOK(t *testing.T, w *httptest.ResponseRecorder, want int, what string) {
t.Helper()
if w.Code != want {
t.Fatalf("%s: want %d got %d body=%q", what, want, w.Code, w.Body.String())
}
}
+70 -215
View File
@@ -1,89 +1,29 @@
package handlers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"booklib/internal/bookfile"
"booklib/internal/ports"
"booklib/internal/upload"
)
// 分片上传:init(指纹→确定性 uploadId,天然支持续传)→ PUT parts → complete 拼接原子落盘。
// 会话即 <BooksDir>/.uploads/<uid>/(meta.json + parts/N),无独立状态存储;24h 未 complete opportunistic 清扫。
// 分片上传:HTTP 层只做参数绑定与错误映射,域逻辑(指纹续传/分片落盘/拼接/清扫)
// 全部在 internal/upload;会话清扫由 scanner ticker 接管(B16),不在请求路径。
const (
maxChunkBytes = 32 << 20
defaultChunk = 8 << 20
uploadSessTTL = 24 * time.Hour
uploadSessionIn = ".uploads"
)
type uploadMeta struct {
Name string `json:"name"`
Size int64 `json:"size"`
ChunkSize int64 `json:"chunkSize"`
LibraryID int64 `json:"libraryId"`
}
func validUploadID(s string) bool {
if len(s) != 32 {
return false
}
for _, r := range s {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return false
}
}
return true
}
func uploadIDFor(libID int64, name string, size, chunk int64) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%d", libID, name, size, chunk)))
return hex.EncodeToString(h[:16])
}
func (h *H) uploadDir(uid string) string {
return filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn, uid)
}
func chunkRange(m uploadMeta, i int64) (int64, int64) {
lo := i * m.ChunkSize
hi := min(lo+m.ChunkSize, m.Size)
return lo, hi
}
func (h *H) numParts(m uploadMeta) int64 {
return (m.Size + m.ChunkSize - 1) / m.ChunkSize
}
// sweepUploads 删除过期会话目录;尽力而为,失败不影响主流程
func (h *H) sweepUploads() {
base := filepath.Join(filepath.Clean(h.cfg.BooksDir), uploadSessionIn)
es, e := os.ReadDir(base)
if e != nil {
return
}
for _, en := range es {
if fi, e := en.Info(); e == nil && time.Since(fi.ModTime()) > uploadSessTTL {
os.RemoveAll(filepath.Join(base, en.Name()))
}
}
}
// maxChunkBytes 与 upload 包内常量同值,作为 PutPart 的防御性读取上限。
const maxChunkBytes = 32 << 20
func (h *H) UploadInit(c *gin.Context) {
lib, ok := h.getLibrary(c)
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
@@ -99,146 +39,47 @@ func (h *H) UploadInit(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "name, size required")
return
}
name := bookfile.SafeName(req.Name)
if name == "" {
err(c, http.StatusBadRequest, "bad_request", "bad name")
return
}
if bookfile.FormatFromExt(name) == "" {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
if req.Size <= 0 {
err(c, http.StatusBadRequest, "bad_request", "bad size")
return
}
if req.Size > h.cfg.UploadMaxMB<<20 {
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
return
}
if req.ChunkSize == 0 {
req.ChunkSize = defaultChunk
}
if req.ChunkSize > maxChunkBytes {
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
return
}
uid := uploadIDFor(lib.ID, name, req.Size, req.ChunkSize)
dir := h.uploadDir(uid)
meta := uploadMeta{Name: name, Size: req.Size, ChunkSize: req.ChunkSize, LibraryID: lib.ID}
if b, e := os.ReadFile(filepath.Join(dir, "meta.json")); e == nil {
var old uploadMeta
if json.Unmarshal(b, &old) == nil && old == meta { // 同指纹 → 复用会话(续传)
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
return
}
os.RemoveAll(dir) // 指纹撞上但内容不同 → 从头再来
}
h.sweepUploads()
if e := os.MkdirAll(filepath.Join(dir, "parts"), 0o755); e != nil {
err(c, http.StatusInternalServerError, "internal", "create session")
return
}
b, _ := json.Marshal(meta)
if e := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); e != nil {
err(c, http.StatusInternalServerError, "internal", "write meta")
uid, e := h.up.Init(c, lib.ID, req.Name, req.Size, req.ChunkSize)
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusOK, gin.H{"uploadId": uid})
}
// loadMeta 校验 uid 与路径,404/400 已回复
func (h *H) loadMeta(c *gin.Context) (uploadMeta, string, bool) {
uid := c.Param("uid")
if !validUploadID(uid) {
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
return uploadMeta{}, "", false
}
dir := h.uploadDir(uid)
var m uploadMeta
b, e := os.ReadFile(filepath.Join(dir, "meta.json"))
if e != nil {
err(c, http.StatusNotFound, "not_found", "no such upload")
return uploadMeta{}, "", false
}
if json.Unmarshal(b, &m) != nil {
err(c, http.StatusInternalServerError, "internal", "corrupt session")
return uploadMeta{}, "", false
}
return m, dir, true
}
func (h *H) UploadStatus(c *gin.Context) {
_, dir, ok := h.loadMeta(c)
if !ok {
recv, e := h.up.Status(c, c.Param("uid"))
if e != nil {
h.mapUploadErr(c, e)
return
}
recv := []int64{}
es, e := os.ReadDir(filepath.Join(dir, "parts"))
if e == nil {
for _, en := range es {
if i, e := strconv.ParseInt(en.Name(), 10, 64); e == nil {
recv = append(recv, i)
}
}
}
sort.Slice(recv, func(i, j int) bool { return recv[i] < recv[j] })
c.JSON(http.StatusOK, gin.H{"received": recv})
}
func (h *H) UploadPart(c *gin.Context) {
m, dir, ok := h.loadMeta(c)
if !ok {
return
}
uid := c.Param("uid")
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
if e != nil || idx < 0 || idx >= h.numParts(m) {
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad part index")
return
}
lo, hi := chunkRange(m, idx)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, hi-lo)
p := filepath.Join(dir, "parts", strconv.FormatInt(idx, 10))
f, e := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
// maxSize 防御性上限:分片声明大小由会话 meta 决定,这里再垫一层 32MB 全局上限
e = h.up.PutPart(c, uid, idx, c.Request.Body, maxChunkBytes)
if e != nil {
err(c, http.StatusInternalServerError, "internal", "create part")
return
}
n, e := io.Copy(f, c.Request.Body)
f.Close()
if e != nil || n != hi-lo {
os.Remove(p)
var mbe *http.MaxBytesError
code, msg := "too_large", "part size mismatch"
if errors.As(e, &mbe) {
msg = "part exceeds declared size"
}
err(c, http.StatusRequestEntityTooLarge, code, msg)
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (h *H) UploadComplete(c *gin.Context) {
m, dir, ok := h.loadMeta(c)
if !ok {
uid := c.Param("uid")
libID, e := h.up.LibraryID(c, uid)
if e != nil {
h.mapUploadErr(c, e)
return
}
var total int64
for i := int64(0); i < h.numParts(m); i++ {
lo, hi := chunkRange(m, i)
fi, e := os.Stat(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
if e != nil || fi.Size() != hi-lo {
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
return
}
total += fi.Size()
}
if total != m.Size {
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
return
}
lib, e := h.st.GetLibrary(c, m.LibraryID)
lib, e := h.libs.GetLibrary(c, libID)
if e != nil {
dbErr(c, e)
return
@@ -247,39 +88,53 @@ func (h *H) UploadComplete(c *gin.Context) {
if !ok {
return
}
dst, e := h.uniquePath(root, m.Name)
rel, e := h.up.Complete(c, uid, root)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
h.mapUploadErr(c, e)
return
}
tmp := filepath.Join(dir, "assembled")
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if e != nil {
err(c, http.StatusInternalServerError, "internal", "create tmp")
return
}
for i := int64(0); i < h.numParts(m); i++ {
pf, e := os.Open(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
if e != nil {
out.Close()
err(c, http.StatusInternalServerError, "internal", "open part")
return
}
if _, e := io.Copy(out, pf); e != nil {
pf.Close()
out.Close()
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "assemble")
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": rel})
}
// mapUploadErr 把 upload 包的 sentinel 错误映射为原契约的 status/code/message。
func (h *H) mapUploadErr(c *gin.Context, e error) {
switch {
case errors.Is(e, ports.ErrTooLarge):
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
case errors.Is(e, upload.ErrBadName):
err(c, http.StatusBadRequest, "bad_request", "bad name")
case errors.Is(e, upload.ErrBadFormat):
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
case errors.Is(e, upload.ErrBadSize):
err(c, http.StatusBadRequest, "bad_request", "bad size")
case errors.Is(e, upload.ErrBadChunk):
err(c, http.StatusBadRequest, "bad_request", "chunkSize must be <= 33554432")
case errors.Is(e, upload.ErrBadUploadID):
err(c, http.StatusBadRequest, "bad_request", "bad upload id")
case errors.Is(e, upload.ErrBadIndex):
err(c, http.StatusBadRequest, "bad_request", "bad part index")
case errors.Is(e, upload.ErrPartTooBig):
err(c, http.StatusRequestEntityTooLarge, "too_large", "part exceeds declared size")
case errors.Is(e, upload.ErrPartSizeMismatch):
err(c, http.StatusRequestEntityTooLarge, "too_large", "part size mismatch")
case errors.Is(e, ports.ErrNotFound):
err(c, http.StatusNotFound, "not_found", "no such upload")
case errors.Is(e, upload.ErrCorrupt):
err(c, http.StatusInternalServerError, "internal", "corrupt session")
case errors.Is(e, ports.ErrIncomplete):
err(c, http.StatusBadRequest, "bad_request", "upload incomplete; missing or corrupt parts, re-upload them")
case errors.Is(e, ports.ErrSizeMismatch):
err(c, http.StatusBadRequest, "bad_request", "total size mismatch")
case errors.Is(e, os.ErrInvalid), errors.Is(e, os.ErrExist):
err(c, http.StatusForbidden, "forbidden", e.Error())
case errors.Is(e, os.ErrPermission), errors.Is(e, os.ErrClosed):
err(c, http.StatusInternalServerError, "internal", "io error")
default:
var oe *upload.OpError
if errors.As(e, &oe) {
err(c, http.StatusInternalServerError, "internal", oe.Op)
return
}
pf.Close()
}
out.Close()
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename")
return
err(c, http.StatusInternalServerError, "internal", "upload failed")
}
os.RemoveAll(dir)
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
}
+10 -23
View File
@@ -3,23 +3,18 @@ package handlers
import (
"errors"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"booklib/internal/auth"
"booklib/internal/ports"
"booklib/internal/store"
)
func isUnique(e error) bool {
var pgErr *pgconn.PgError
return errors.As(e, &pgErr) && pgErr.Code == "23505"
}
func (h *H) ListUsers(c *gin.Context) {
users, e := h.st.ListUsers(c)
users, e := h.users.ListUsers(c)
if e != nil {
dbErr(c, e)
return
@@ -51,9 +46,9 @@ func (h *H) CreateUser(c *gin.Context) {
err(c, http.StatusInternalServerError, "internal", "hash")
return
}
id, e := h.st.CreateUser(c, req.Username, hp, req.Role)
id, e := h.users.CreateUser(c, req.Username, hp, req.Role)
if e != nil {
if isUnique(e) {
if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "username taken")
return
}
@@ -64,32 +59,24 @@ func (h *H) CreateUser(c *gin.Context) {
}
func (h *H) DeleteUser(c *gin.Context) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
id, ok := idParam(c)
if !ok {
return
}
if id == uid(c) {
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
return
}
target, e := h.st.GetUserByID(c, id)
if e != nil {
// B5: transactional last-admin check eliminates TOCTOU race.
if e := h.users.DeleteUser(c, id); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
err(c, http.StatusNotFound, "not_found", "no such user")
return
}
dbErr(c, e)
return
}
if target.Role == "admin" {
n, _ := h.st.CountAdmins(c) // 防删光最后一个 admin
if n <= 1 {
if errors.Is(e, store.ErrLastAdmin) {
err(c, http.StatusBadRequest, "bad_request", "cannot delete the last admin")
return
}
}
if e := h.st.DeleteUser(c, id); e != nil {
dbErr(c, e)
return
}
@@ -0,0 +1,154 @@
package handlers_test
import (
"context"
"errors"
"net/http"
"strconv"
"testing"
"github.com/jackc/pgx/v5"
"booklib/internal/auth"
)
// ---------- users: admin-only CRUD branches (Task 27, portsfake) ----------
func TestUnit_ListUsers(t *testing.T) {
e := newTestEnv(t)
hash, _ := auth.HashPassword("password1234")
e.users.Seed("admin", hash, "admin")
e.users.Seed("member1", hash, "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodGet, "/api/users", atok, nil)
httpOK(t, w, 200, "list users")
var users []map[string]any
if err := jsonUnmarshal(w, &users); err != nil {
t.Fatal(err)
}
if len(users) != 2 {
t.Fatalf("want 2 users got %d", len(users))
}
}
func TestUnit_ListUsers_MemberForbidden(t *testing.T) {
e := newTestEnv(t)
mtok := e.token(t, "member", 2)
w := e.do(t, http.MethodGet, "/api/users", mtok, nil)
httpOK(t, w, 403, "member list users")
}
func TestUnit_CreateUser_RoleValidation(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
for _, role := range []string{"superadmin", "", "Member"} {
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "u", "Password": "password1234", "Role": role})
httpOK(t, w, 400, "create user role="+role)
}
}
func TestUnit_CreateUser_ShortPassword(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "u", "Password": "short", "Role": "member"})
httpOK(t, w, 400, "short password")
}
func TestUnit_CreateUser_DuplicateName(t *testing.T) {
e := newTestEnv(t)
hash, _ := auth.HashPassword("password1234")
e.users.Seed("existing", hash, "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "existing", "Password": "password1234", "Role": "member"})
httpOK(t, w, 409, "duplicate name")
if code := errCode(t, w); code != "exists" {
t.Fatalf("error code want 'exists' got %q", code)
}
}
func TestUnit_CreateUser_OK(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodPost, "/api/users", atok,
map[string]string{"Username": "newbie", "Password": "password1234", "Role": "member"})
httpOK(t, w, 201, "create user")
body := jsonBody(t, w)
if body["username"] != "newbie" || body["role"] != "member" {
t.Fatalf("unexpected body %v", body)
}
}
func TestUnit_DeleteUser_BadID(t *testing.T) {
e := newTestEnv(t)
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/notanum", atok, nil)
httpOK(t, w, 400, "bad id")
}
func TestUnit_DeleteUser_Self(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("self", "hash", "admin")
atok := e.token(t, "admin", uid) // 自己删自己
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
httpOK(t, w, 400, "delete self")
}
func TestUnit_DeleteUser_LastAdmin(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("lastadmin", "hash", "admin")
atok := e.token(t, "admin", 999) // 另一个(不存在的)操作者
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(uid, 10), atok, nil)
httpOK(t, w, 400, "last admin")
}
func TestUnit_DeleteUser_NotFound(t *testing.T) {
e := newTestEnv(t)
e.users.Seed("other", "hash", "admin")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/99999", atok, nil)
httpOK(t, w, 404, "missing user")
}
func TestUnit_DeleteUser_OK(t *testing.T) {
e := newTestEnv(t)
e.users.Seed("admin", "hash", "admin") // 保住 last-admin 保护不触发
target := e.users.Seed("todelete", "hash", "member")
atok := e.token(t, "admin", 1)
w := e.do(t, http.MethodDelete, "/api/users/"+strconv.FormatInt(target, 10), atok, nil)
httpOK(t, w, 204, "delete user")
if _, err := e.users.GetUserByID(context.Background(), target); !errors.Is(err, pgx.ErrNoRows) {
t.Fatal("user should be gone")
}
}
func TestUnit_Me_OK(t *testing.T) {
e := newTestEnv(t)
uid := e.users.Seed("me", "hash", "member")
mtok := e.token(t, "member", uid)
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
httpOK(t, w, 200, "me")
body := jsonBody(t, w)
if body["username"] != "me" {
t.Fatalf("username want 'me' got %v", body["username"])
}
}
func TestUnit_Me_UserGone(t *testing.T) {
e := newTestEnv(t)
mtok := e.token(t, "member", 99999)
w := e.do(t, http.MethodGet, "/api/auth/me", mtok, nil)
httpOK(t, w, 401, "me after user gone")
}
func TestUnit_Me_NoToken(t *testing.T) {
e := newTestEnv(t)
w := e.do(t, http.MethodGet, "/api/auth/me", "", nil)
httpOK(t, w, 401, "me without token")
}
+19 -4
View File
@@ -12,10 +12,12 @@ import (
"booklib/cmd/webui/api"
"booklib/internal/config"
"booklib/internal/db"
"booklib/internal/media"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/seed"
"booklib/internal/store"
"booklib/internal/upload"
)
func main() {
@@ -39,18 +41,31 @@ func main() {
log.Fatalf("seed: %v", err)
}
rdb := redispkg.New(cfg.RedisURL)
sc := scanner.New(st, cfg, rdb)
med := media.New(cfg, rdb)
up := upload.New(cfg.BooksDir, cfg.UploadMaxMB)
sc := scanner.New(st, cfg, rdb, up) // B16: sweep rides the scan ticker
go sc.Run(ctx)
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, rdb, sc),
serveErr := make(chan error, 1)
// *store.Store 同时满足 5 个 store 接口;*redispkg.R 满足 RateLimiter。
srv := &http.Server{Addr: cfg.Addr, Handler: api.NewRouter(cfg, st, st, st, st, st, rdb, sc, med, up),
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)
serveErr <- err
}
close(serveErr)
}()
<-ctx.Done()
// Wait for signal OR serve error.
select {
case <-ctx.Done():
case err := <-serveErr:
if err != nil {
log.Printf("serve: %v", err)
}
}
// stop() 先取消 ctx → scanner 循环退出;再等 HTTP 收尾。
// 在途 ScanLibraryByID(WithoutCancel)不受 ctx 控制,靠 redis 锁 TTL 兜底(已文档化的上限)。
stop()
+37
View File
@@ -0,0 +1,37 @@
package bookfile
import (
"os"
"path/filepath"
"strings"
)
// OpenReaderAt opens a book file and returns a ReaderAt + size.
// Consolidates the 3 places that open a book file + stat + get ReaderAt.
func OpenReaderAt(root, rel string) (*os.File, int64, error) {
abs := filepath.Join(root, filepath.FromSlash(rel))
f, err := os.Open(abs)
if err != nil {
return nil, 0, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, st.Size(), nil
}
// Contains reports whether child is inside parent using EvalSymlinks semantics.
// Falls back to Clean if EvalSymlinks fails (e.g., path doesn't exist yet).
func Contains(parent, child string) bool {
p, err := filepath.EvalSymlinks(parent)
if err != nil {
p = filepath.Clean(parent)
}
c, err := filepath.EvalSymlinks(child)
if err != nil {
c = filepath.Clean(child)
}
return c == p || strings.HasPrefix(c, p+string(os.PathSeparator))
}
+51
View File
@@ -0,0 +1,51 @@
package bookfile
import (
"os"
"path/filepath"
"testing"
)
func TestContains(t *testing.T) {
dir := t.TempDir()
sub := filepath.Join(dir, "sub")
if err := os.MkdirAll(sub, 0o755); err != nil {
t.Fatal(err)
}
if !Contains(dir, sub) {
t.Fatal("sub should be inside dir")
}
if Contains(sub, dir) {
t.Fatal("dir should not be inside sub")
}
if Contains(dir, "/completely/different") {
t.Fatal("unrelated path should not be inside dir")
}
// Same path.
if !Contains(dir, dir) {
t.Fatal("dir should contain itself")
}
}
func TestOpenReaderAt(t *testing.T) {
dir := t.TempDir()
// Create a test file.
path := filepath.Join(dir, "test.txt")
if err := os.WriteFile(path, []byte("hello"), 0o644); err != nil {
t.Fatal(err)
}
f, size, err := OpenReaderAt(dir, "test.txt")
if err != nil {
t.Fatal(err)
}
defer f.Close()
if size != 5 {
t.Fatalf("size = %d, want 5", size)
}
// Non-existent file.
_, _, err = OpenReaderAt(dir, "nope.txt")
if err == nil {
t.Fatal("expected error for non-existent file")
}
}
+15
View File
@@ -2,11 +2,14 @@ package config
import (
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type Config struct {
@@ -52,6 +55,18 @@ func Load() (*Config, error) {
if secret == "" {
return nil, fmt.Errorf("JWT_SECRET required")
}
// Validate required fields.
dbURL := env("DATABASE_URL", "")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
if _, perr := pgxpool.ParseConfig(dbURL); perr != nil {
return nil, fmt.Errorf("DATABASE_URL: %w", perr)
}
if env("REDIS_URL", "") == "" {
log.Printf("redis disabled: rate-limit/scan-lock/page-cache off")
}
// resolveDir: macOS 开发机上 /var、/tmp 是指向 /private 的软链,
// 启动时解析一次才能让 root_path 前缀校验对上真实路径;目录不存在/出错则保留原值(Docker 路径不受影响)
resolveDir := func(dir string) string {
+16
View File
@@ -6,6 +6,22 @@ import (
"time"
)
func TestLoadDatabaseURLRequired(t *testing.T) {
t.Setenv("JWT_SECRET", "x")
t.Setenv("DATABASE_URL", "")
if _, err := Load(); err == nil {
t.Fatal("empty DATABASE_URL must fail")
}
}
func TestLoadDatabaseURLMalformed(t *testing.T) {
t.Setenv("JWT_SECRET", "x")
t.Setenv("DATABASE_URL", "not a url")
if _, err := Load(); err == nil {
t.Fatal("malformed DATABASE_URL must fail")
}
}
func TestLoad(t *testing.T) {
// 屏蔽外部 env,保证默认值断言自洽(Load 将空串视为未设置)
t.Setenv("BOOKS_DIR", "")
+125 -5
View File
@@ -2,14 +2,26 @@ package db
import (
"context"
_ "embed"
"embed"
"fmt"
"io/fs"
"log"
"regexp"
"sort"
"strings"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed schema.sql
var schema string
//go:embed migrations
var migrationsFS embed.FS
// advisoryLockKey is a fixed int64 used with pg_advisory_lock to serialize
// migrations across --scale api=N replicas. Value is arbitrary but must be
// unique within the database (pick a project-specific constant).
const advisoryLockKey int64 = 0x424C4D49 // "BLMI"
var migrationNameRe = regexp.MustCompile(`^\d{4}_[a-z0-9_]+\.sql$`)
func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(url)
@@ -21,8 +33,116 @@ func Connect(ctx context.Context, url string) (*pgxpool.Pool, error) {
}
func Migrate(ctx context.Context, p *pgxpool.Pool) error {
if _, err := p.Exec(ctx, schema); err != nil {
return fmt.Errorf("migrate: %w", err)
// 1. Acquire advisory lock — serializes concurrent replicas.
if _, err := p.Exec(ctx, "SELECT pg_advisory_lock($1)", advisoryLockKey); err != nil {
return fmt.Errorf("advisory lock: %w", err)
}
defer func() {
if _, err := p.Exec(ctx, "SELECT pg_advisory_unlock($1)", advisoryLockKey); err != nil {
log.Printf("advisory unlock: %v", err)
}
}()
// 2. Create tracking table.
if _, err := p.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// 3. Read embedded migration files, validate names.
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var files []string
for _, e := range entries {
name := e.Name()
if !migrationNameRe.MatchString(name) {
panic(fmt.Sprintf("invalid migration filename: %q (must match %s)", name, migrationNameRe))
}
files = append(files, name)
}
sort.Strings(files)
// 4. Baseline detection: if schema_migrations is empty but 'books' table exists,
// this is an existing database — mark 0001 as applied without re-running DDL.
var count int
if err := p.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil {
return fmt.Errorf("count migrations: %w", err)
}
if count == 0 {
var hasBooks bool
err := p.QueryRow(ctx, "SELECT to_regclass('books') IS NOT NULL").Scan(&hasBooks)
if err != nil {
return fmt.Errorf("check books table: %w", err)
}
if hasBooks && len(files) > 0 && strings.HasPrefix(files[0], "0001_") {
if _, err := p.Exec(ctx,
"INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
1, files[0]); err != nil {
return fmt.Errorf("baseline insert: %w", err)
}
log.Printf("migration baseline: marked %s as applied (existing database)", files[0])
files = files[1:]
}
}
// 5. Build set of already-applied versions.
applied := map[int64]bool{}
rows, err := p.Query(ctx, "SELECT version FROM schema_migrations")
if err != nil {
return fmt.Errorf("list applied: %w", err)
}
defer rows.Close()
for rows.Next() {
var v int64
if err := rows.Scan(&v); err != nil {
return fmt.Errorf("scan applied: %w", err)
}
applied[v] = true
}
if err := rows.Err(); err != nil {
return fmt.Errorf("rows applied: %w", err)
}
// 6. Apply pending migrations in order, each in its own transaction.
for _, name := range files {
version := parseVersion(name)
if applied[version] {
continue
}
sql, err := fs.ReadFile(migrationsFS, "migrations/"+name)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
tx, err := p.Begin(ctx)
if err != nil {
return fmt.Errorf("begin %s: %w", name, err)
}
if _, err := tx.Exec(ctx, string(sql)); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("exec %s: %w", name, err)
}
if _, err := tx.Exec(ctx,
"INSERT INTO schema_migrations (version, name) VALUES ($1, $2)",
version, name); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("record %s: %w", name, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit %s: %w", name, err)
}
log.Printf("migration applied: %s", name)
}
return nil
}
func parseVersion(name string) int64 {
parts := strings.SplitN(name, "_", 2)
var v int64
fmt.Sscanf(parts[0], "%d", &v)
return v
}
+165
View File
@@ -0,0 +1,165 @@
package media
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"booklib/internal/bookfile"
"booklib/internal/config"
"booklib/internal/ports"
"booklib/internal/redispkg"
)
// M is the media service — single source of truth for cache layout and content extraction.
type M struct {
cfg *config.Config
rdb *redispkg.R
}
// compile-time proof that *M satisfies the consumer-side interface.
var _ ports.Media = (*M)(nil)
func New(cfg *config.Config, rdb *redispkg.R) *M {
return &M{cfg: cfg, rdb: rdb}
}
// CacheBuster returns a content hash for cache-busting URLs.
func (m *M) CacheBuster(size, modTS int64) string {
return bookfile.Hash(size, modTS)
}
// EnsureCover extracts and caches the cover image for a book.
// Returns nil if the cover is already cached or the format doesn't support covers.
func (m *M) EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error {
var fn func(io.ReaderAt, int64) ([]byte, string, error)
switch format {
case "cbz":
fn = bookfile.CBZCover
case "epub":
fn = bookfile.EPUBCover
default:
return nil
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return err
}
defer f.Close()
img, ext, err := fn(f, fsize)
if err != nil {
return err
}
dir := bookfile.CoverDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
return WriteAtomic(dir, "cover"+ext, img)
}
// PageIndex returns the page list for a CBZ, using redis cache when available.
func (m *M) PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error) {
hash := bookfile.Hash(size, modTS)
key := fmt.Sprintf("pagesidx2:%d:%s", bookID, hash)
if v, ok := m.rdb.Get(ctx, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return nil, err
}
defer f.Close()
idx, err := bookfile.PageIndex(f, fsize)
if err != nil {
return nil, err
}
if len(idx) > 0 {
m.rdb.Set(ctx, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
}
// ChaptersOf derives chapters from a page index by grouping pages by parent directory.
func (m *M) ChaptersOf(idx []string) []ports.Chapter {
type grp struct {
dir string
start int
}
var grps []grp
last := "\x00"
for i, n := range idx {
d := path.Dir(n)
if d == last {
continue
}
last = d
if d == "." {
continue
}
grps = append(grps, grp{d, i})
}
if len(grps) < 2 {
return nil
}
titles := make(map[string]int)
out := make([]ports.Chapter, len(grps))
for i, g := range grps {
out[i] = ports.Chapter{Title: path.Base(g.dir), Start: g.start}
titles[out[i].Title]++
}
for i, g := range grps {
if titles[out[i].Title] > 1 {
out[i].Title = g.dir
}
}
return out
}
// EnsurePage extracts a single page to the cache. Returns the cache file path.
func (m *M) EnsurePage(_ context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error) {
if n >= len(idx) {
return "", fmt.Errorf("page %d out of range", n)
}
ext := strings.ToLower(filepath.Ext(idx[n]))
dir := bookfile.PagesDir(m.cfg.CacheDir, bookfile.DirKey(bookID, bookfile.Hash(size, modTS)))
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
if _, err := os.Stat(dst); err == nil {
return dst, nil // already cached
}
f, fsize, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
return "", err
}
defer f.Close()
data, err := bookfile.ReadEntry(f, fsize, idx[n])
if err != nil {
return "", err
}
if err := WriteAtomic(dir, strconv.Itoa(n)+ext, data); err != nil {
return "", err
}
return dst, nil
}
// WriteAtomic writes data to dir/name via tmp+rename. B11: cleans tmp only on
// failure, so a crash mid-write never leaves a readable half-written file.
// It does not log — callers own the context (which book, which page).
func WriteAtomic(dir, name string, data []byte) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp := filepath.Join(dir, fmt.Sprintf("%s.tmp-%d", name, time.Now().UnixNano()))
if err := os.WriteFile(tmp, data, 0o644); err != nil {
os.Remove(tmp)
return err
}
dst := filepath.Join(dir, name)
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return err
}
return nil
}
+16
View File
@@ -0,0 +1,16 @@
package media
import "strings"
// reservedNames are library names that conflict with system directories.
// This is the single source of truth for reserved name validation (B8).
var reservedNames = map[string]bool{
"cache": true, // CACHE_DIR
".uploads": true, // upload session directory
".trash": true, // potential future use
}
// IsReservedName reports whether name conflicts with system directories.
func IsReservedName(name string) bool {
return reservedNames[strings.ToLower(name)]
}
+16
View File
@@ -0,0 +1,16 @@
package media
import "testing"
func TestIsReservedName(t *testing.T) {
for _, n := range []string{"cache", "Cache", "CACHE", ".uploads", ".Uploads", ".trash"} {
if !IsReservedName(n) {
t.Errorf("IsReservedName(%q) = false, want true", n)
}
}
for _, n := range []string{"comics", "books", "my-library", "Cache1"} {
if IsReservedName(n) {
t.Errorf("IsReservedName(%q) = true, want false", n)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
package ports
import (
"errors"
"booklib/internal/store"
)
// Re-export sentinel errors so handlers can use errors.Is via ports.
var (
ErrLastAdmin = store.ErrLastAdmin
ErrUniqueViolation = store.ErrUniqueViolation
)
// IsUniqueViolation consolidates the pg 23505 check into a single predicate.
func IsUniqueViolation(err error) bool { return store.IsUniqueViolation(err) }
// Upload sentinel errors.
var (
ErrTooLarge = errors.New("file too large")
ErrIncomplete = errors.New("upload incomplete")
ErrSizeMismatch = errors.New("size mismatch")
ErrNotFound = errors.New("not found")
)
+107
View File
@@ -0,0 +1,107 @@
// Package ports defines consumer-side interfaces for the backend services.
// Handlers depend on these interfaces, not on concrete implementations.
// Main.go wires concrete types (*store.Store, *redispkg.R, etc.) that satisfy them.
//
// Value types (User, Book, etc.) live in internal/store and are referenced here.
package ports
import (
"context"
"io"
"time"
"booklib/internal/store"
)
// ---------- Store interfaces ----------
type UserStore interface {
CountUsers(ctx context.Context) (int, error)
CreateUser(ctx context.Context, username, hash, role string) (int64, error)
GetUserByName(ctx context.Context, username string) (store.User, error)
GetUserByID(ctx context.Context, id int64) (store.User, error)
ListUsers(ctx context.Context) ([]store.User, error)
DeleteUser(ctx context.Context, id int64) error
}
type LibraryStore interface {
CreateLibrary(ctx context.Context, name, root string) (int64, error)
ListLibraries(ctx context.Context) ([]store.Library, error)
GetLibrary(ctx context.Context, id int64) (store.Library, error)
}
type BookStore interface {
InsertBook(ctx context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error)
GetBook(ctx context.Context, id int64) (store.Book, error)
ListBookMeta(ctx context.Context, libID int64) (map[string]store.BookMeta, error)
UpdateBookFile(ctx context.Context, id, size, modTS int64, pageCount int) error
DeleteBookByPath(ctx context.Context, libID int64, path string) error
DeleteBook(ctx context.Context, id int64) error
SetBookState(ctx context.Context, id int64, state, msg string) error
ListBooks(ctx context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error)
BookHashes(ctx context.Context) (map[int64][2]int64, error)
}
type ProgressStore interface {
UpsertProgress(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error
GetProgress(ctx context.Context, userID, libID int64, bookPath string) (store.Progress, error)
ListProgress(ctx context.Context, userID int64) ([]store.Progress, error)
}
type BookmarkStore interface {
InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error)
ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error)
UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error)
DeleteBookmark(ctx context.Context, userID, id int64) (bool, error)
}
// ---------- Redis interfaces ----------
type PageCache interface {
Get(ctx context.Context, key string) (string, bool)
Set(ctx context.Context, key, val string, ttl time.Duration)
}
type RateLimiter interface {
IncrWindow(ctx context.Context, key string, ttl time.Duration) int
}
type ScanLocker interface {
ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool)
}
// ---------- Service interfaces ----------
type Scanner interface {
ScanLibraryByID(ctx context.Context, id int64)
}
type UploadSessions interface {
Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error)
// LibraryID returns the target library recorded in the session, so the
// handler can resolve+validate the library root before Complete.
LibraryID(ctx context.Context, uploadID string) (int64, error)
Status(ctx context.Context, uploadID string) ([]int64, error)
PutPart(ctx context.Context, uploadID string, index int64, body io.Reader, maxSize int64) error
Complete(ctx context.Context, uploadID string, root string) (string, error)
Sweep(ctx context.Context) error
UniquePath(root, name string) (string, error)
}
type Media interface {
// EnsureCover extracts+caches the cover if not already cached. Returns nil
// for formats without cover support.
EnsureCover(ctx context.Context, bookID int64, format string, size, modTS int64, root, rel string) error
// EnsurePage extracts page n (from a pre-fetched idx) to the cache and
// returns the cache file path.
EnsurePage(ctx context.Context, bookID int64, size, modTS int64, root, rel string, n int, idx []string) (string, error)
ChaptersOf(idx []string) []Chapter
PageIndex(ctx context.Context, bookID int64, size, modTS int64, root, rel string) ([]string, error)
CacheBuster(size, modTS int64) string
}
// Chapter represents a CBZ chapter derived from the archive's folder structure.
type Chapter struct {
Title string `json:"title"`
Start int `json:"start"`
}
@@ -0,0 +1,198 @@
package portsfake
import (
"context"
"fmt"
"io"
"sync"
"time"
"booklib/internal/ports"
)
// ---------- RateLimiter ----------
// RateLimiter counts IncrWindow calls per key and returns a programmable
// window value. Default: always allow (return 1).
type RateLimiter struct {
mu sync.Mutex
calls map[string]int
Result int // value returned by IncrWindow; 0 means "call count"
Hook func(key string, n int) int // optional override
}
func NewRateLimiter() *RateLimiter { return &RateLimiter{calls: map[string]int{}} }
func (r *RateLimiter) IncrWindow(_ context.Context, key string, _ time.Duration) int {
r.mu.Lock()
r.calls[key]++
n := r.calls[key]
r.mu.Unlock()
if r.Hook != nil {
return r.Hook(key, n)
}
if r.Result > 0 {
return r.Result
}
return n
}
// Calls reports how many times IncrWindow was invoked for key.
func (r *RateLimiter) Calls(key string) int {
r.mu.Lock()
defer r.mu.Unlock()
return r.calls[key]
}
// ---------- Scanner ----------
// Scanner records ScanLibraryByID calls. The handler invokes it in a goroutine,
// so reads go through the mutex; WaitForScan blocks until at least n calls
// landed (or the timeout expires) to keep tests deterministic.
type Scanner struct {
mu sync.Mutex
seen []int64
}
func NewScanner() *Scanner { return &Scanner{} }
func (s *Scanner) ScanLibraryByID(_ context.Context, id int64) {
s.mu.Lock()
s.seen = append(s.seen, id)
s.mu.Unlock()
}
// Seen returns the library ids scanned so far, in call order.
func (s *Scanner) Seen() []int64 {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]int64, len(s.seen))
copy(out, s.seen)
return out
}
// WaitForScan blocks until len(Seen()) >= n or timeout elapses; reports success.
func (s *Scanner) WaitForScan(n int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if len(s.Seen()) >= n {
return true
}
time.Sleep(time.Millisecond)
}
return len(s.Seen()) >= n
}
// ---------- Media ----------
// Media is a programmable stand-in for the media service. Defaults answer
// successfully with the configured fixture data; individual hooks let a test
// force one error branch without touching the others.
type Media struct {
Pages []string // PageIndex result
Chapters []ports.Chapter // ChaptersOf result
CoverErr error // EnsureCover result
IndexErr error // PageIndex result
PagePath string // EnsurePage result path (test writes the file first)
PageErr error // EnsurePage result
EnsureCoverCalls int
EnsurePageCalls int
}
func NewMedia() *Media { return &Media{} }
func (m *Media) EnsureCover(_ context.Context, _ int64, _ string, _, _ int64, _, _ string) error {
m.EnsureCoverCalls++
return m.CoverErr
}
func (m *Media) EnsurePage(_ context.Context, _ int64, _, _ int64, _, _ string, _ int, _ []string) (string, error) {
m.EnsurePageCalls++
if m.PageErr != nil {
return "", m.PageErr
}
return m.PagePath, nil
}
func (m *Media) ChaptersOf(_ []string) []ports.Chapter { return m.Chapters }
func (m *Media) PageIndex(_ context.Context, _ int64, _, _ int64, _, _ string) ([]string, error) {
if m.IndexErr != nil {
return nil, m.IndexErr
}
return m.Pages, nil
}
func (m *Media) CacheBuster(size, modTS int64) string { return fmt.Sprintf("%d-%d", size, modTS) }
// ---------- UploadSessions ----------
// Uploads is a programmable stand-in for the chunked-upload subsystem.
// Defaults simulate a happy session (uid "fakeuid…", LibraryID 1);
// hooks force error branches.
type Uploads struct {
UID string // returned by Init
LibID int64 // returned by LibraryID
Received []int64 // returned by Status
RelPath string // returned by Complete
InitErr error
LibErr error
StatusErr error
PutErr error
CompleteErr error
PutCalls []int64 // part indices passed to PutPart
SweepCalls int
UniqueCalls int
}
func NewUploads() *Uploads {
return &Uploads{UID: "0123456789abcdef0123456789abcdef", LibID: 1, RelPath: "book.cbz"}
}
func (u *Uploads) Init(_ context.Context, _ int64, _ string, _, _ int64) (string, error) {
if u.InitErr != nil {
return "", u.InitErr
}
return u.UID, nil
}
func (u *Uploads) LibraryID(_ context.Context, _ string) (int64, error) {
if u.LibErr != nil {
return 0, u.LibErr
}
return u.LibID, nil
}
func (u *Uploads) Status(_ context.Context, _ string) ([]int64, error) {
if u.StatusErr != nil {
return nil, u.StatusErr
}
return u.Received, nil
}
func (u *Uploads) PutPart(_ context.Context, _ string, index int64, body io.Reader, _ int64) error {
u.PutCalls = append(u.PutCalls, index)
if body != nil { // drain so callers using pipes don't block
io.Copy(io.Discard, body)
}
return u.PutErr
}
func (u *Uploads) Complete(_ context.Context, _, _ string) (string, error) {
if u.CompleteErr != nil {
return "", u.CompleteErr
}
return u.RelPath, nil
}
func (u *Uploads) Sweep(_ context.Context) error {
u.SweepCalls++
return nil
}
func (u *Uploads) UniquePath(root, name string) (string, error) {
u.UniqueCalls++
return root + "/" + name, nil
}
+384
View File
@@ -0,0 +1,384 @@
// Package portsfake provides hand-written, in-memory implementations of every
// interface in internal/ports. They let handler tests run without PG or Redis,
// while reproducing the real store's error semantics exactly (pgx.ErrNoRows for
// missing rows, store.ErrLastAdmin / store.ErrUniqueViolation for the guarded
// paths), so the branches under test behave as they do against the database.
//
// Fakes are safe for single-goroutine test use only; they are not locked.
package portsfake
import (
"context"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"booklib/internal/ports"
"booklib/internal/store"
)
// uniqueViolation 复刻真 PG 的唯一约束冲突:store.IsUniqueViolation 只认
// SQLSTATE 23505 的 *pgconn.PgError(生产链路里 pgx 原样透出),fake 必须同形。
func uniqueViolation(constraint string) error {
return &pgconn.PgError{Code: "23505", ConstraintName: constraint}
}
// compile-time proof that the fakes satisfy the consumer-side interfaces.
var (
_ ports.UserStore = (*Users)(nil)
_ ports.LibraryStore = (*Libraries)(nil)
_ ports.BookStore = (*Books)(nil)
_ ports.ProgressStore = (*Progress)(nil)
_ ports.BookmarkStore = (*Bookmarks)(nil)
_ ports.RateLimiter = (*RateLimiter)(nil)
_ ports.Scanner = (*Scanner)(nil)
_ ports.Media = (*Media)(nil)
_ ports.UploadSessions = (*Uploads)(nil)
)
// now is frozen per-fake-set so CreatedAt comparisons are stable within a test.
func now() time.Time { return time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC) }
// ---------- UserStore ----------
type Users struct {
m map[int64]store.User
next int64
}
func NewUsers() *Users { return &Users{m: map[int64]store.User{}, next: 1} }
// Seed adds a user and returns its id — test convenience, not part of the port.
func (u *Users) Seed(username, hash, role string) int64 {
id := u.next
u.next++
u.m[id] = store.User{ID: id, Username: username, PasswordHash: hash, Role: role, CreatedAt: now()}
return id
}
func (u *Users) CountUsers(_ context.Context) (int, error) { return len(u.m), nil }
func (u *Users) CreateUser(_ context.Context, username, hash, role string) (int64, error) {
for _, v := range u.m {
if v.Username == username {
return 0, uniqueViolation("users_username_key")
}
}
return u.Seed(username, hash, role), nil
}
func (u *Users) GetUserByName(_ context.Context, username string) (store.User, error) {
for _, v := range u.m {
if v.Username == username {
return v, nil
}
}
return store.User{}, pgx.ErrNoRows
}
func (u *Users) GetUserByID(_ context.Context, id int64) (store.User, error) {
if v, ok := u.m[id]; ok {
return v, nil
}
return store.User{}, pgx.ErrNoRows
}
func (u *Users) ListUsers(_ context.Context) ([]store.User, error) {
out := make([]store.User, 0, len(u.m))
for _, v := range u.m {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (u *Users) DeleteUser(_ context.Context, id int64) error {
v, ok := u.m[id]
if !ok {
return pgx.ErrNoRows
}
if v.Role == "admin" {
n := 0
for _, x := range u.m {
if x.Role == "admin" {
n++
}
}
if n <= 1 {
return store.ErrLastAdmin
}
}
delete(u.m, id)
return nil
}
// ---------- LibraryStore ----------
type Libraries struct {
m map[int64]store.Library
next int64
}
func NewLibraries() *Libraries { return &Libraries{m: map[int64]store.Library{}, next: 1} }
// Seed adds a library and returns its id — test convenience.
func (l *Libraries) Seed(name, root string) int64 {
id := l.next
l.next++
l.m[id] = store.Library{ID: id, Name: name, RootPath: root, CreatedAt: now()}
return id
}
func (l *Libraries) CreateLibrary(_ context.Context, name, root string) (int64, error) {
for _, v := range l.m {
if v.Name == name {
return 0, uniqueViolation("libraries_name_key")
}
}
return l.Seed(name, root), nil
}
func (l *Libraries) ListLibraries(_ context.Context) ([]store.Library, error) {
out := make([]store.Library, 0, len(l.m))
for _, v := range l.m {
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
}
func (l *Libraries) GetLibrary(_ context.Context, id int64) (store.Library, error) {
if v, ok := l.m[id]; ok {
return v, nil
}
return store.Library{}, pgx.ErrNoRows
}
// ---------- BookStore ----------
type Books struct {
m map[int64]store.Book
next int64
}
func NewBooks() *Books { return &Books{m: map[int64]store.Book{}, next: 1} }
// Seed adds a book and returns its id — test convenience.
func (b *Books) Seed(libID int64, path, title, format string, size, modTS int64, pageCount int) int64 {
id := b.next
b.next++
b.m[id] = store.Book{ID: id, LibraryID: libID, Path: path, Title: title, Format: format,
FileSize: size, ModTS: modTS, PageCount: pageCount, State: "ok", AddedAt: now()}
return id
}
func (b *Books) InsertBook(_ context.Context, libID int64, path, title, format string, size, modTS int64, pageCount int) (int64, error) {
return b.Seed(libID, path, title, format, size, modTS, pageCount), nil
}
func (b *Books) GetBook(_ context.Context, id int64) (store.Book, error) {
if v, ok := b.m[id]; ok {
return v, nil
}
return store.Book{}, pgx.ErrNoRows
}
func (b *Books) ListBookMeta(_ context.Context, libID int64) (map[string]store.BookMeta, error) {
out := map[string]store.BookMeta{}
for _, v := range b.m {
if v.LibraryID == libID {
out[v.Path] = store.BookMeta{ID: v.ID, Size: v.FileSize, ModTS: v.ModTS, Format: v.Format}
}
}
return out, nil
}
func (b *Books) UpdateBookFile(_ context.Context, id, size, modTS int64, pageCount int) error {
v, ok := b.m[id]
if !ok {
return pgx.ErrNoRows
}
v.FileSize, v.ModTS, v.PageCount = size, modTS, pageCount
b.m[id] = v
return nil
}
func (b *Books) DeleteBookByPath(_ context.Context, libID int64, path string) error {
for id, v := range b.m {
if v.LibraryID == libID && v.Path == path {
delete(b.m, id)
return nil
}
}
return nil
}
func (b *Books) DeleteBook(_ context.Context, id int64) error {
if _, ok := b.m[id]; !ok {
return pgx.ErrNoRows
}
delete(b.m, id)
return nil
}
func (b *Books) SetBookState(_ context.Context, id int64, state, msg string) error {
v, ok := b.m[id]
if !ok {
return pgx.ErrNoRows
}
v.State, v.ErrMsg = state, msg
b.m[id] = v
return nil
}
// ListBooks applies the same q/prefix filter as the SQL view (q matches title or
// path, prefix matches path prefix), then joins progress + library name.
func (b *Books) ListBooks(_ context.Context, libID int64, q, prefix string, userID int64) ([]store.BookView, error) {
out := []store.BookView{}
q = strings.ToLower(q)
for _, v := range b.m {
if libID != 0 && v.LibraryID != libID {
continue
}
if prefix != "" && !strings.HasPrefix(v.Path, prefix) {
continue
}
if q != "" && !strings.Contains(strings.ToLower(v.Title), q) && !strings.Contains(strings.ToLower(v.Path), q) {
continue
}
out = append(out, store.BookView{Book: v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Book.ID < out[j].Book.ID })
return out, nil
}
func (b *Books) BookHashes(_ context.Context) (map[int64][2]int64, error) {
out := map[int64][2]int64{}
for _, v := range b.m {
out[v.ID] = [2]int64{v.FileSize, v.ModTS}
}
return out, nil
}
// ---------- ProgressStore ----------
type progressKey struct {
userID, libID int64
bookPath string
}
type Progress struct {
m map[progressKey]store.Progress
libs *Libraries // for LibraryName join; may be nil
books *Books // for Title join; may be nil
}
func NewProgress(libs *Libraries, books *Books) *Progress {
return &Progress{m: map[progressKey]store.Progress{}, libs: libs, books: books}
}
func (p *Progress) UpsertProgress(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64) error {
k := progressKey{userID, libID, bookPath}
old := p.m[k]
p.m[k] = store.Progress{LibraryID: libID, BookPath: bookPath, Locator: locator,
Percent: percent, UpdatedAt: now(), LibraryName: old.LibraryName, Title: old.Title}
return nil
}
func (p *Progress) GetProgress(_ context.Context, userID, libID int64, bookPath string) (store.Progress, error) {
if v, ok := p.m[progressKey{userID, libID, bookPath}]; ok {
return v, nil
}
return store.Progress{}, pgx.ErrNoRows
}
func (p *Progress) ListProgress(_ context.Context, userID int64) ([]store.Progress, error) {
out := []store.Progress{}
for k, v := range p.m {
if k.userID != userID {
continue
}
if p.libs != nil {
if l, e := p.libs.GetLibrary(context.Background(), v.LibraryID); e == nil {
v.LibraryName = l.Name
}
}
if p.books != nil {
for _, b := range p.books.m {
if b.LibraryID == v.LibraryID && b.Path == v.BookPath {
v.Title = b.Title
break
}
}
}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].BookPath < out[j].BookPath })
return out, nil
}
// ---------- BookmarkStore ----------
// bookmark pairs a row with its owner id. The real store carries the owner in a
// users-scoped join; the fake keeps it alongside so ListBookmarks/Patch/Delete
// can honour owner-scoped 404 semantics without a global index.
type bookmark struct {
row store.Bookmark
userID int64
}
type Bookmarks struct {
m map[int64]bookmark
next int64
}
func NewBookmarks() *Bookmarks { return &Bookmarks{m: map[int64]bookmark{}, next: 1} }
func (bm *Bookmarks) InsertBookmark(_ context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
id := bm.next
bm.next++
bm.m[id] = bookmark{
row: store.Bookmark{ID: id, LibraryID: libID, BookPath: bookPath,
Locator: locator, Percent: percent, Note: note, CreatedAt: now()},
userID: userID,
}
return id, nil
}
func (bm *Bookmarks) ListBookmarks(_ context.Context, userID, libID int64, bookPath string) ([]store.Bookmark, error) {
out := []store.Bookmark{}
for _, v := range bm.m {
if v.userID == userID && v.row.LibraryID == libID && v.row.BookPath == bookPath {
out = append(out, v.row)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Percent != out[j].Percent {
return out[i].Percent < out[j].Percent
}
return out[i].ID < out[j].ID
})
return out, nil
}
func (bm *Bookmarks) UpdateBookmarkNote(_ context.Context, userID, id int64, note string) (bool, error) {
v, ok := bm.m[id]
if !ok || v.userID != userID { // owner-scoped: foreign id is a 404, not a 403
return false, nil
}
v.row.Note = note
bm.m[id] = v
return true, nil
}
func (bm *Bookmarks) DeleteBookmark(_ context.Context, userID, id int64) (bool, error) {
v, ok := bm.m[id]
if !ok || v.userID != userID {
return false, nil
}
delete(bm.m, id)
return true, nil
}
+80 -9
View File
@@ -44,18 +44,25 @@ func (r *R) Set(ctx context.Context, key, val string, ttl time.Duration) {
}
}
// incrWindowScript atomically increments and sets TTL on first value,
// preventing the INCR+EXPIRE race that could leave keys without TTL (B1).
var incrWindowScript = redis.NewScript(`
local n = redis.call('INCR', KEYS[1])
if n == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return n
`)
func (r *R) IncrWindow(ctx context.Context, key string, ttl time.Duration) int {
if r.c == nil {
return 1
}
n, err := r.c.Incr(ctx, key).Result()
n, err := incrWindowScript.Run(ctx, r.c, []string{key}, int(ttl.Seconds())).Int()
if err != nil {
return 1
return 1 // fail-open
}
if n == 1 {
r.c.Expire(ctx, key, ttl)
}
return int(n)
return n
}
func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bool) {
@@ -64,7 +71,11 @@ func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bo
return noop, true
}
b := make([]byte, 8)
rand.Read(b)
if _, err := rand.Read(b); err != nil {
// B2: rand failure → degrade to no-lock instead of using a zero token.
log.Printf("rand.Read failed: %v (proceeding without lock)", err)
return noop, true
}
tok := hex.EncodeToString(b)
ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
if err != nil { // spec §9: Redis 故障降级放行,锁只做尽力去重
@@ -75,8 +86,68 @@ func (r *R) Lock(ctx context.Context, key string, ttl time.Duration) (func(), bo
return noop, false // 锁被持有,别的副本在扫
}
return func() {
r.c.Eval(ctx,
// B3: use WithoutCancel so unlock survives caller cancellation.
if err := r.c.Eval(context.WithoutCancel(ctx),
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
[]string{key}, tok)
[]string{key}, tok).Err(); err != nil {
log.Printf("redis unlock %s: %v", key, err)
}
}, true
}
// ScanLock acquires a distributed lock with automatic renewal.
// The lock is renewed every ttl/2 until unlock is called.
// Returns (unlock, true) on success, (noop, true) on redis failure (degrade),
// or (noop, false) if the lock is already held.
func (r *R) ScanLock(ctx context.Context, key string, ttl time.Duration) (func(), bool) {
noop := func() {}
if r.c == nil {
return noop, true
}
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
log.Printf("rand.Read failed: %v (proceeding without lock)", err)
return noop, true
}
tok := hex.EncodeToString(b)
ok, err := r.c.SetNX(ctx, key, tok, ttl).Result()
if err != nil {
log.Printf("redis scanlock %s: %v (proceeding without lock)", key, err)
return noop, true
}
if !ok {
return noop, false
}
// Start renewal goroutine.
done := make(chan struct{})
go func() {
ticker := time.NewTicker(ttl / 2)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
// Renew only if we still own the lock.
if err := r.c.Eval(context.Background(),
`if redis.call('get',KEYS[1])==ARGV[1] then
return redis.call('expire',KEYS[1],ARGV[2])
else return 0 end`,
[]string{key}, tok, int(ttl.Seconds())).Err(); err != nil {
log.Printf("redis scanlock renew %s: %v", key, err)
}
}
}
}()
unlock := func() {
close(done) // stop renewal
if err := r.c.Eval(context.WithoutCancel(ctx),
"if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end",
[]string{key}, tok).Err(); err != nil {
log.Printf("redis scanlock unlock %s: %v", key, err)
}
}
return unlock, true
}
+100
View File
@@ -2,6 +2,7 @@ package redispkg
import (
"context"
"os"
"testing"
"time"
)
@@ -21,6 +22,12 @@ func TestDisabledIsSafe(t *testing.T) {
t.Fatal("disabled Lock must always acquire")
}
un()
// ScanLock disabled mode.
un2, ok2 := r.ScanLock(ctx, "slk", time.Second)
if !ok2 {
t.Fatal("disabled ScanLock must always acquire")
}
un2()
}
func TestDeadRedisLockFailsOpen(t *testing.T) {
@@ -31,3 +38,96 @@ func TestDeadRedisLockFailsOpen(t *testing.T) {
}
un()
}
func TestDeadRedisScanLockFailsOpen(t *testing.T) {
r := New("redis://127.0.0.1:16399")
un, ok := r.ScanLock(context.Background(), "slk", time.Second)
if !ok {
t.Fatal("ScanLock on redis error must fail open")
}
un()
}
func newLiveRedis(t *testing.T) *R {
t.Helper()
url := os.Getenv("REDIS_URL")
if url == "" {
t.Skip("REDIS_URL not set")
}
return New(url)
}
func TestIncrWindowSetsTTL(t *testing.T) {
r := newLiveRedis(t)
ctx := context.Background()
key := "test:incrwindow:ttl:" + t.Name()
r.c.Del(ctx, key)
n := r.IncrWindow(ctx, key, 5*time.Second)
if n != 1 {
t.Fatalf("first call = %d, want 1", n)
}
ttl, err := r.c.TTL(ctx, key).Result()
if err != nil {
t.Fatal(err)
}
if ttl <= 0 {
t.Fatalf("TTL should be positive after first increment, got %v", ttl)
}
// Second call: n=2, TTL should still be positive.
n = r.IncrWindow(ctx, key, 5*time.Second)
if n != 2 {
t.Fatalf("second call = %d, want 2", n)
}
r.c.Del(ctx, key)
}
func TestScanLockRenewal(t *testing.T) {
r := newLiveRedis(t)
ctx := context.Background()
key := "test:scanlock:" + t.Name()
r.c.Del(ctx, key)
unlock, ok := r.ScanLock(ctx, key, 2*time.Second)
if !ok {
t.Fatal("should acquire")
}
// Wait 3 seconds — without renewal, the lock would expire at 2s.
time.Sleep(3 * time.Second)
// A second attempt should fail (lock still held by first, renewed).
_, ok2 := r.ScanLock(ctx, key, 2*time.Second)
if ok2 {
t.Fatal("second acquire should fail — lock should have been renewed")
}
unlock()
r.c.Del(ctx, key)
}
func TestScanLockMutualExclusion(t *testing.T) {
r := newLiveRedis(t)
ctx := context.Background()
key := "test:scanlock:mutex:" + t.Name()
r.c.Del(ctx, key)
unlock1, ok1 := r.ScanLock(ctx, key, 10*time.Second)
if !ok1 {
t.Fatal("first should acquire")
}
_, ok2 := r.ScanLock(ctx, key, 10*time.Second)
if ok2 {
t.Fatal("second should not acquire while first holds")
}
unlock1()
// After unlock, a new acquire should succeed.
time.Sleep(50 * time.Millisecond)
unlock3, ok3 := r.ScanLock(ctx, key, 10*time.Second)
if !ok3 {
t.Fatal("should acquire after unlock")
}
unlock3()
r.c.Del(ctx, key)
}
+75 -80
View File
@@ -7,25 +7,35 @@ import (
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"booklib/internal/bookfile"
"booklib/internal/config"
"booklib/internal/media"
"booklib/internal/redispkg"
"booklib/internal/store"
)
type Scanner struct {
st *store.Store
cfg *config.Config
rdb *redispkg.R
// Sweeper 是 scanner 每轮顺手调用的清理钩子;upload.U 满足它(B16)。
type Sweeper interface {
Sweep(ctx context.Context) error
}
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R) *Scanner {
return &Scanner{st: st, cfg: cfg, rdb: rdb}
type Scanner struct {
st *store.Store
cfg *config.Config
rdb *redispkg.R
sweepers []Sweeper
// B9-②: per-library single-flight — concurrent scan triggers for the same
// library are merged into one execution, even without redis.
flights sync.Map // map[int64]*sync.WaitGroup
}
func New(st *store.Store, cfg *config.Config, rdb *redispkg.R, sweepers ...Sweeper) *Scanner {
return &Scanner{st: st, cfg: cfg, rdb: rdb, sweepers: sweepers}
}
func (s *Scanner) Run(ctx context.Context) {
@@ -36,13 +46,18 @@ func (s *Scanner) Run(ctx context.Context) {
case <-ctx.Done():
return
case <-t.C:
for _, sw := range s.sweepers { // B16: 上传会话清扫随扫描周期跑
if err := sw.Sweep(ctx); err != nil {
log.Printf("scan: sweep: %v", err)
}
}
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)
s.scanOnce(ctx, l)
}
}
}
@@ -54,19 +69,35 @@ func (s *Scanner) ScanLibraryByID(ctx context.Context, id int64) {
log.Printf("scan: library %d: %v", id, err)
return
}
s.scanOnce(ctx, lib)
}
// scanOnce ensures only one scan per library runs concurrently in this process.
// Concurrent callers block until the in-flight scan completes (B9-②).
func (s *Scanner) scanOnce(ctx context.Context, lib store.Library) {
wg := &sync.WaitGroup{}
wg.Add(1)
if existing, loaded := s.flights.LoadOrStore(lib.ID, wg); loaded {
existing.(*sync.WaitGroup).Wait()
return
}
defer func() {
s.flights.Delete(lib.ID)
wg.Done()
}()
s.ScanLibrary(ctx, lib)
}
func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
// ponytail: 5min lock TTL; a scan longer than this lets another replica join — refresh mid-walk if libs ever outgrow it
unlock, ok := s.rdb.Lock(ctx, fmt.Sprintf("scan:%d", lib.ID), 5*time.Minute)
// B9-①: ScanLock auto-renews every TTL/2 during long scans.
unlock, ok := s.rdb.ScanLock(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) {
if err != nil || !bookfile.Contains(s.cfg.BooksDir, root) {
log.Printf("scan: library %d root %q rejected", lib.ID, lib.RootPath)
return
}
@@ -85,9 +116,9 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
delete(dbMeta, rel)
switch {
case !exists:
s.add(ctx, lib.ID, root, rel, ds)
s.ingest(ctx, lib.ID, 0, root, rel, ds, true)
case old.Size != ds.size || old.ModTS != ds.modTS:
s.update(ctx, lib.ID, old.ID, root, rel, ds)
s.ingest(ctx, lib.ID, old.ID, root, rel, ds, false)
}
}
for rel := range dbMeta { // 只剩被删的文件
@@ -100,11 +131,6 @@ func (s *Scanner) ScanLibrary(ctx context.Context, lib store.Library) {
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 {
@@ -137,9 +163,9 @@ func titleOf(rel string) string {
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) {
// ingest 是 add/update 的合一实现(Task 24):isNew 决定走 Insert 还是 UpdateBookFile,
// 之后的错误处理与封面生成完全共享。bookID 仅在 isNew=false 时有意义。
func (s *Scanner) ingest(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat, isNew bool) {
format := bookfile.FormatFromExt(filepath.Base(rel))
pageCount := 0
var idxErr error
@@ -147,98 +173,67 @@ func (s *Scanner) add(ctx context.Context, libID int64, root, rel string, ds dis
idx, err := s.zipIndex(root, rel)
pageCount = len(idx)
idxErr = err
if idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
if idxErr == nil && pageCount == 0 {
idxErr = errors.New("no images in archive")
}
}
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 idxErr == nil && pageCount == 0 { // 视频/文档 zip 不是漫画,空白 reader 没有意义
idxErr = errors.New("no images in archive")
if isNew {
id, err := s.st.InsertBook(ctx, libID, rel, titleOf(rel), format, ds.size, ds.modTS, pageCount)
if err != nil {
log.Printf("scan: insert %s: %v", rel, err)
return
}
}
if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
bookID = id
} else if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
log.Printf("scan: update %s: %v", rel, err)
return
}
if idxErr != nil {
s.st.SetBookState(ctx, bookID, "error", idxErr.Error())
// B10: log SetBookState errors instead of discarding.
if e := s.st.SetBookState(ctx, bookID, "error", idxErr.Error()); e != nil {
log.Printf("scan: SetBookState %s: %v", rel, e)
}
return
}
s.cover(ctx, bookID, root, rel, format, ds)
}
func (s *Scanner) zipIndex(root, rel string) ([]string, error) {
f, err := os.Open(filepath.Join(root, filepath.FromSlash(rel)))
f, size, err := bookfile.OpenReaderAt(root, 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())
return bookfile.PageIndex(f, size)
}
// cover 失败(坏 epub、无图等)只 log — 书的 state 由 PageIndex 判定,封面缺了有占位 SVG 兜底
// cover writes the cover image to the cache dir via media.WriteAtomic.
// B11: write failures are logged; orphan .tmp files are cleaned only on failure.
func (s *Scanner) cover(ctx context.Context, id int64, root, rel, format string, ds diskStat) {
var img []byte
var ext string
var err error
var fn func(io.ReaderAt, int64) ([]byte, string, error)
switch format {
case "cbz":
img, ext, err = s.readCover(root, rel, bookfile.CBZCover)
fn = bookfile.CBZCover
case "epub":
img, ext, err = s.readCover(root, rel, bookfile.EPUBCover)
fn = bookfile.EPUBCover
default:
return // pdf/txt/md 用占位 SVG,不落盘
}
f, size, err := bookfile.OpenReaderAt(root, rel)
if err != nil {
log.Printf("scan: cover %s: %v", rel, err)
return
}
defer f.Close()
img, ext, err := fn(f, size)
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
if e := media.WriteAtomic(dir, "cover"+ext, img); e != nil {
log.Printf("scan: cover write %s: %v", rel, e)
}
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(io.ReaderAt, 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) {
+1 -5
View File
@@ -2,11 +2,8 @@ package seed
import (
"context"
"errors"
"log"
"github.com/jackc/pgx/v5/pgconn"
"booklib/internal/auth"
"booklib/internal/store"
)
@@ -28,8 +25,7 @@ func Admin(ctx context.Context, s *store.Store, user, pass string) error {
return err
}
if _, err := s.CreateUser(ctx, user, h, "admin"); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if store.IsUniqueViolation(err) {
log.Printf("seed admin %q may already exist: %v", user, err)
return nil
}
+48
View File
@@ -0,0 +1,48 @@
package store
import "context"
func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
var id int64
err := s.p.QueryRow(ctx,
`INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
userID, libID, bookPath, locator, percent, note).Scan(&id)
return id, err
}
func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) {
rows, err := s.p.Query(ctx,
`SELECT id, library_id, book_path, locator, percent, note, created_at
FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3
ORDER BY percent ASC, id ASC`, userID, libID, bookPath)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Bookmark
for rows.Next() {
var b Bookmark
if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
res, err := s.p.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
if err != nil {
return false, err
}
return res.RowsAffected() > 0, nil
}
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
res, err := s.p.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
if err != nil {
return false, err
}
return res.RowsAffected() > 0, nil
}
+109
View File
@@ -0,0 +1,109 @@
package store
import "context"
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) 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()
}
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()
}
+35
View File
@@ -0,0 +1,35 @@
package store
import "context"
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
}
+45
View File
@@ -0,0 +1,45 @@
package store
import "context"
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) 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
}
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()
}
+27 -310
View File
@@ -1,16 +1,29 @@
package store
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct{ P *pgxpool.Pool }
// ErrLastAdmin is returned by DeleteUser when attempting to delete the last admin.
var ErrLastAdmin = errors.New("cannot delete the last admin")
func New(p *pgxpool.Pool) *Store { return &Store{P: p} }
// ErrUniqueViolation is a sentinel for PostgreSQL unique constraint violations.
var ErrUniqueViolation = errors.New("unique violation")
// IsUniqueViolation reports whether err is a PostgreSQL unique constraint violation (23505).
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
// Store provides database access. The pool is unexported; all access goes through methods.
type Store struct{ p *pgxpool.Pool }
func New(p *pgxpool.Pool) *Store { return &Store{p: p} }
// ---------- types ----------
@@ -21,12 +34,14 @@ type User struct {
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
@@ -34,288 +49,35 @@ type Book struct {
Format string
FileSize int64
ModTS int64
PageCount int // 0 表示未知(pdf/epub/txt)
PageCount int // 0 means unknown (pdf/epub/txt)
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 // 书已删时为空
Title string // empty when book deleted
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()
}
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()
}
// ---------- 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()
}
// ---------- bookmarks ----------
type Bookmark struct {
ID int64
LibraryID int64
@@ -326,51 +88,6 @@ type Bookmark struct {
CreatedAt time.Time
}
func (s *Store) InsertBookmark(ctx context.Context, userID, libID int64, bookPath string, locator []byte, percent float64, note string) (int64, error) {
var id int64
err := s.P.QueryRow(ctx,
`INSERT INTO bookmarks (user_id, library_id, book_path, locator, percent, note)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
userID, libID, bookPath, locator, percent, note).Scan(&id)
return id, err
}
func (s *Store) ListBookmarks(ctx context.Context, userID, libID int64, bookPath string) ([]Bookmark, error) {
rows, err := s.P.Query(ctx,
`SELECT id, library_id, book_path, locator, percent, note, created_at
FROM bookmarks WHERE user_id=$1 AND library_id=$2 AND book_path=$3
ORDER BY percent ASC, id ASC`, userID, libID, bookPath)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Bookmark
for rows.Next() {
var b Bookmark
if err := rows.Scan(&b.ID, &b.LibraryID, &b.BookPath, &b.Locator, &b.Percent, &b.Note, &b.CreatedAt); err != nil {
return nil, err
}
out = append(out, b)
}
return out, rows.Err()
}
func (s *Store) UpdateBookmarkNote(ctx context.Context, userID, id int64, note string) (bool, error) {
res, err := s.P.Exec(ctx, `UPDATE bookmarks SET note=$3 WHERE id=$1 AND user_id=$2`, id, userID, note)
return res.RowsAffected() > 0, err
}
func (s *Store) DeleteBookmark(ctx context.Context, userID, id int64) (bool, error) {
res, err := s.P.Exec(ctx, `DELETE FROM bookmarks WHERE id=$1 AND user_id=$2`, id, userID)
return res.RowsAffected() > 0, 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
}
// Pool returns the underlying pool for test setup only.
// Production code should use Store methods exclusively.
func (s *Store) Pool() *pgxpool.Pool { return s.p }
+48
View File
@@ -2,6 +2,7 @@ package store
import (
"context"
"errors"
"os"
"testing"
@@ -129,3 +130,50 @@ func TestProgressUpsertAndJoin(t *testing.T) {
t.Fatalf("views %+v", views)
}
}
func TestDeleteUserLastAdmin(t *testing.T) {
s := setup(t)
ctx := context.Background()
id, err := s.CreateUser(ctx, "onlyadmin", "hash", "admin")
if err != nil {
t.Fatal(err)
}
err = s.DeleteUser(ctx, id)
if !errors.Is(err, ErrLastAdmin) {
t.Fatalf("expected ErrLastAdmin, got %v", err)
}
// Verify user still exists.
_, err = s.GetUserByID(ctx, id)
if err != nil {
t.Fatal("admin should still exist after blocked delete")
}
}
func TestDeleteUserNonLastAdmin(t *testing.T) {
s := setup(t)
ctx := context.Background()
id1, _ := s.CreateUser(ctx, "admin1", "h", "admin")
_, _ = s.CreateUser(ctx, "admin2", "h", "admin")
if err := s.DeleteUser(ctx, id1); err != nil {
t.Fatalf("should allow deleting non-last admin: %v", err)
}
}
func TestDeleteUserMember(t *testing.T) {
s := setup(t)
ctx := context.Background()
id, _ := s.CreateUser(ctx, "member", "h", "member")
if err := s.DeleteUser(ctx, id); err != nil {
t.Fatalf("member delete should succeed: %v", err)
}
}
func TestIsUniqueViolation(t *testing.T) {
s := setup(t)
ctx := context.Background()
_, _ = s.CreateUser(ctx, "dup", "h", "member")
_, err := s.CreateUser(ctx, "dup", "h", "member")
if !IsUniqueViolation(err) {
t.Fatalf("expected unique violation, got %v", err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"github.com/jackc/pgx/v5"
)
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()
}
// DeleteUser deletes a user with a transactional last-admin check (B5).
// Returns ErrLastAdmin if the target is the last admin.
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
tx, err := s.p.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
var role string
if err := tx.QueryRow(ctx, "SELECT role FROM users WHERE id=$1 FOR UPDATE", id).Scan(&role); err != nil {
return err // includes pgx.ErrNoRows
}
if role == "admin" {
var n int
if err := tx.QueryRow(ctx, "SELECT count(*) FROM users WHERE role='admin'").Scan(&n); err != nil {
return err
}
if n <= 1 {
return ErrLastAdmin
}
}
if _, err := tx.Exec(ctx, "DELETE FROM users WHERE id=$1", id); err != nil {
return err
}
return tx.Commit(ctx)
}
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
}
+357
View File
@@ -0,0 +1,357 @@
// Package upload owns the resumable chunked-upload subsystem and the shared
// unique-path placement helper used by both chunked and single-file uploads.
//
// A session lives at <BooksDir>/.uploads/<uid>/ (meta.json + parts/N). The uid
// is a fingerprint of (libID, name, size, chunkSize), so re-initialising the
// same file resumes the existing session instead of restarting it. Expired
// sessions are swept by the scanner ticker (B16), not on the request path.
//
// This package is HTTP-free: it returns sentinel errors that the handlers map
// onto status/code/message tuples. The prior client-facing contract is
// preserved exactly.
package upload
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"booklib/internal/bookfile"
"booklib/internal/ports"
)
const (
maxChunkBytes = 32 << 20
defaultChunk = 8 << 20
uploadSessTTL = 24 * time.Hour
uploadSessionIn = ".uploads"
)
// Sentinel errors returned to handlers for status/code mapping. The four shared
// with ports (ErrTooLarge/ErrNotFound/ErrIncomplete/ErrSizeMismatch) live in the
// ports package so the interface contract and the implementation agree.
var (
ErrBadName = errors.New("bad name")
ErrBadFormat = errors.New("bad format")
ErrBadSize = errors.New("bad size")
ErrBadChunk = errors.New("bad chunk size")
ErrBadUploadID = errors.New("bad upload id")
ErrBadIndex = errors.New("bad part index")
ErrPartTooBig = errors.New("part exceeds declared size")
ErrPartSizeMismatch = errors.New("part size mismatch")
ErrCorrupt = errors.New("corrupt session")
)
// OpError wraps an internal filesystem/IO failure with the short operation label
// that the client-facing 500 message uses, preserving the prior contract strings
// ("create session", "write meta", "create part", ...).
type OpError struct {
Op string
Err error
}
func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }
func opErr(op string, err error) error { return &OpError{Op: op, Err: err} }
type uploadMeta struct {
Name string `json:"name"`
Size int64 `json:"size"`
ChunkSize int64 `json:"chunkSize"`
LibraryID int64 `json:"libraryId"`
}
// U is the upload subsystem. It is stateless beyond the filesystem session dir.
type U struct {
booksDir string
uploadMaxMB int64
}
// New builds the upload subsystem. booksDir is the storage root (sessions live
// under booksDir/.uploads); uploadMaxMB caps the total declared file size.
func New(booksDir string, uploadMaxMB int64) *U {
return &U{booksDir: booksDir, uploadMaxMB: uploadMaxMB}
}
// compile-time proof that *U satisfies the consumer-side interface.
var _ ports.UploadSessions = (*U)(nil)
// ---------- pure helpers ----------
func validUploadID(s string) bool {
if len(s) != 32 {
return false
}
for _, r := range s {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return false
}
}
return true
}
func uploadIDFor(libID int64, name string, size, chunk int64) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%d|%s|%d|%d", libID, name, size, chunk)))
return hex.EncodeToString(h[:16])
}
func (u *U) uploadDir(uid string) string {
return filepath.Join(filepath.Clean(u.booksDir), uploadSessionIn, uid)
}
func chunkRange(m uploadMeta, i int64) (int64, int64) {
lo := i * m.ChunkSize
hi := min(lo+m.ChunkSize, m.Size)
return lo, hi
}
func numParts(m uploadMeta) int64 {
return (m.Size + m.ChunkSize - 1) / m.ChunkSize
}
// ---------- session meta ----------
// loadMeta validates uid + reads meta.json. Returns ErrBadUploadID,
// ports.ErrNotFound, or ErrCorrupt on failure.
func (u *U) loadMeta(uid string) (uploadMeta, string, error) {
if !validUploadID(uid) {
return uploadMeta{}, "", ErrBadUploadID
}
dir := u.uploadDir(uid)
b, e := os.ReadFile(filepath.Join(dir, "meta.json"))
if e != nil {
return uploadMeta{}, "", ports.ErrNotFound
}
var m uploadMeta
if json.Unmarshal(b, &m) != nil {
return uploadMeta{}, "", ErrCorrupt
}
return m, dir, nil
}
// LibraryID returns the target library id recorded in the session, so the
// handler can resolve+validate the library root before calling Complete.
func (u *U) LibraryID(ctx context.Context, uid string) (int64, error) {
m, _, e := u.loadMeta(uid)
if e != nil {
return 0, e
}
return m.LibraryID, nil
}
// ---------- public API (satisfies ports.UploadSessions) ----------
// Init validates the declared upload, then creates or resumes a session. The
// returned uid is deterministic for a given (libID, safeName, size, chunkSize),
// so a re-init of the same file resumes; a fingerprint collision with different
// content restarts the session.
func (u *U) Init(ctx context.Context, libID int64, name string, size, chunkSize int64) (string, error) {
safe := bookfile.SafeName(name)
if safe == "" {
return "", ErrBadName
}
if bookfile.FormatFromExt(safe) == "" {
return "", ErrBadFormat
}
if size <= 0 {
return "", ErrBadSize
}
if size > u.uploadMaxMB<<20 {
return "", ports.ErrTooLarge
}
if chunkSize == 0 {
chunkSize = defaultChunk
}
if chunkSize > maxChunkBytes {
return "", ErrBadChunk
}
uid := uploadIDFor(libID, safe, size, chunkSize)
dir := u.uploadDir(uid)
meta := uploadMeta{Name: safe, Size: size, ChunkSize: chunkSize, LibraryID: libID}
if b, e := os.ReadFile(filepath.Join(dir, "meta.json")); e == nil {
var old uploadMeta
if json.Unmarshal(b, &old) == nil && old == meta { // same fingerprint → resume
return uid, nil
}
os.RemoveAll(dir) // fingerprint collided but content differs → restart
}
if e := os.MkdirAll(filepath.Join(dir, "parts"), 0o755); e != nil {
return "", opErr("create session", e)
}
b, _ := json.Marshal(meta)
if e := os.WriteFile(filepath.Join(dir, "meta.json"), b, 0o644); e != nil {
return "", opErr("write meta", e)
}
return uid, nil
}
// Status returns the sorted indices of parts already received.
func (u *U) Status(ctx context.Context, uid string) ([]int64, error) {
_, dir, e := u.loadMeta(uid)
if e != nil {
return nil, e
}
recv := []int64{}
es, e := os.ReadDir(filepath.Join(dir, "parts"))
if e == nil {
for _, en := range es {
if i, e := strconv.ParseInt(en.Name(), 10, 64); e == nil {
recv = append(recv, i)
}
}
}
sort.Slice(recv, func(i, j int) bool { return recv[i] < recv[j] })
return recv, nil
}
// PutPart writes one part via tmp+rename (B4: a truncated part is never reported
// as received). body is read up to the declared part size; reading past it yields
// ErrPartTooBig, a short read yields ErrPartSizeMismatch. maxSize, when > 0, is a
// defensive ceiling on bytes read.
func (u *U) PutPart(ctx context.Context, uid string, index int64, body io.Reader, maxSize int64) error {
m, dir, e := u.loadMeta(uid)
if e != nil {
return e
}
if index < 0 || index >= numParts(m) {
return ErrBadIndex
}
lo, hi := chunkRange(m, index)
want := hi - lo
limit := want + 1
if maxSize > 0 && maxSize+1 < limit {
limit = maxSize + 1
}
p := filepath.Join(dir, "parts", strconv.FormatInt(index, 10))
tmp := p + ".tmp"
f, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if e != nil {
return opErr("create part", e)
}
n, copyErr := io.Copy(f, io.LimitReader(body, limit))
f.Close()
if copyErr != nil {
os.Remove(tmp)
return ErrPartSizeMismatch
}
if n > want {
os.Remove(tmp)
return ErrPartTooBig
}
if n != want {
os.Remove(tmp)
return ErrPartSizeMismatch
}
if e := os.Rename(tmp, p); e != nil {
os.Remove(tmp)
return opErr("rename part", e)
}
return nil
}
// Complete verifies every part is present and correctly sized, assembles them
// into a tmp file, then atomically renames it into root under a unique name.
// It returns the path relative to root. The session dir is removed on success.
func (u *U) Complete(ctx context.Context, uid, root string) (string, error) {
m, dir, e := u.loadMeta(uid)
if e != nil {
return "", e
}
var total int64
for i := int64(0); i < numParts(m); i++ {
lo, hi := chunkRange(m, i)
fi, e := os.Stat(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
if e != nil || fi.Size() != hi-lo {
return "", ports.ErrIncomplete
}
total += fi.Size()
}
if total != m.Size {
return "", ports.ErrSizeMismatch
}
dst, e := u.UniquePath(root, m.Name)
if e != nil {
return "", e // os.ErrInvalid / os.ErrExist → handler maps to 403
}
tmp := filepath.Join(dir, "assembled")
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if e != nil {
return "", opErr("create tmp", e)
}
for i := int64(0); i < numParts(m); i++ {
pf, e := os.Open(filepath.Join(dir, "parts", strconv.FormatInt(i, 10)))
if e != nil {
out.Close()
return "", opErr("open part", e)
}
_, copyErr := io.Copy(out, pf)
pf.Close()
if copyErr != nil {
out.Close()
os.Remove(tmp)
return "", opErr("assemble", copyErr)
}
}
out.Close()
if e := os.Rename(tmp, dst); e != nil { // atomic placement; scanner picks it up
os.Remove(tmp)
return "", opErr("rename", e)
}
os.RemoveAll(dir)
return strings.TrimPrefix(dst, root+string(os.PathSeparator)), nil
}
// Sweep removes session dirs untouched for longer than the TTL. Best-effort:
// a missing/unreadable base dir is not an error. Run from the scanner ticker
// (B16) instead of the request path.
func (u *U) Sweep(ctx context.Context) error {
base := filepath.Join(filepath.Clean(u.booksDir), uploadSessionIn)
es, e := os.ReadDir(base)
if e != nil {
return nil // no sessions yet
}
for _, en := range es {
if fi, e := en.Info(); e == nil && time.Since(fi.ModTime()) > uploadSessTTL {
os.RemoveAll(filepath.Join(base, en.Name()))
}
}
return nil
}
// UniquePath returns a path under root for name that does not yet exist,
// appending " (n)" on collision. The cleaned name must stay inside root.
// Shared by single-file and chunked upload completion.
func (u *U) 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
}
}
}
+252
View File
@@ -0,0 +1,252 @@
package upload
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"booklib/internal/ports"
)
func newU(t *testing.T) *U {
t.Helper()
dir := t.TempDir()
resolved, err := filepath.EvalSymlinks(dir)
if err != nil {
t.Fatal(err)
}
return New(resolved, 1) // 1MB cap, mirrors handler test cfg
}
func mustInit(t *testing.T, u *U, libID int64, name string, size, chunk int64) string {
t.Helper()
uid, err := u.Init(context.Background(), libID, name, size, chunk)
if err != nil {
t.Fatalf("Init(%q): %v", name, err)
}
return uid
}
func TestUploadIDForDeterministic(t *testing.T) {
a := uploadIDFor(1, "x.cbz", 100, 50)
b := uploadIDFor(1, "x.cbz", 100, 50)
c := uploadIDFor(2, "x.cbz", 100, 50)
if a != b {
t.Fatalf("same fingerprint must yield same uid: %s vs %s", a, b)
}
if a == c {
t.Fatal("different libID must yield different uid")
}
if !validUploadID(a) || validUploadID("zzz") || validUploadID(strings.Repeat("a", 31)) {
t.Fatal("validUploadID broken")
}
}
func TestInitValidation(t *testing.T) {
u := newU(t)
ctx := context.Background()
if _, err := u.Init(ctx, 1, "", 100, 0); !errors.Is(err, ErrBadName) {
t.Fatalf("empty name: want ErrBadName got %v", err)
}
if _, err := u.Init(ctx, 1, "virus.exe", 100, 0); !errors.Is(err, ErrBadFormat) {
t.Fatalf("bad ext: want ErrBadFormat got %v", err)
}
if _, err := u.Init(ctx, 1, "ok.cbz", 0, 0); !errors.Is(err, ErrBadSize) {
t.Fatalf("zero size: want ErrBadSize got %v", err)
}
if _, err := u.Init(ctx, 1, "big.cbz", 2<<20, 0); !errors.Is(err, ports.ErrTooLarge) {
t.Fatalf("oversize: want ErrTooLarge got %v", err)
}
if _, err := u.Init(ctx, 1, "ok.cbz", 100, 40<<20); !errors.Is(err, ErrBadChunk) {
t.Fatalf("huge chunk: want ErrBadChunk got %v", err)
}
// 默认 chunk 生效且 uid 稳定
uid := mustInit(t, u, 1, "ok.cbz", 100, 0)
if uid != uploadIDFor(1, "ok.cbz", 100, defaultChunk) {
t.Fatal("chunkSize=0 must default to defaultChunk in fingerprint")
}
// SafeName 清洗:path 形式取 base
if uid2 := mustInit(t, u, 1, `C:\dir\book.cbz`, 100, 0); uid2 != uploadIDFor(1, "book.cbz", 100, defaultChunk) {
t.Fatalf("windows path must be sanitized to base name, got uid %s", uid2)
}
}
func TestInitResumeAndRestart(t *testing.T) {
u := newU(t)
ctx := context.Background()
uid := mustInit(t, u, 1, "r.cbz", 900000, 400000)
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("x"), 400000)), 0); err != nil {
t.Fatalf("PutPart: %v", err)
}
// 同指纹 re-init → 复用会话,分片保留
uid2, err := u.Init(ctx, 1, "r.cbz", 900000, 400000)
if err != nil || uid2 != uid {
t.Fatalf("resume: want same uid, got %s err=%v", uid2, err)
}
recv, err := u.Status(ctx, uid)
if err != nil || len(recv) != 1 || recv[0] != 0 {
t.Fatalf("resume must keep parts: %v err=%v", recv, err)
}
// 同指纹位但内容不同(size 变)→ 新会话,旧目录被换掉是安全的(uid 不同)
uid3 := mustInit(t, u, 1, "r.cbz", 800000, 400000)
if uid3 == uid {
t.Fatal("different size must yield different uid")
}
}
func TestPutPartErrors(t *testing.T) {
u := newU(t)
ctx := context.Background()
uid := mustInit(t, u, 1, "p.cbz", 1000, 400)
if err := u.PutPart(ctx, uid, 9, bytes.NewReader(bytes.Repeat([]byte("y"), 400)), 0); !errors.Is(err, ErrBadIndex) {
t.Fatalf("index oob: want ErrBadIndex got %v", err)
}
if err := u.PutPart(ctx, uid, -1, bytes.NewReader(nil), 0); !errors.Is(err, ErrBadIndex) {
t.Fatalf("negative index: want ErrBadIndex got %v", err)
}
// 超期望体积 → ErrPartTooBig
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("y"), 500)), 0); !errors.Is(err, ErrPartTooBig) {
t.Fatalf("oversize part: want ErrPartTooBig got %v", err)
}
// 短读 → ErrPartSizeMismatch
if err := u.PutPart(ctx, uid, 0, bytes.NewReader(bytes.Repeat([]byte("y"), 300)), 0); !errors.Is(err, ErrPartSizeMismatch) {
t.Fatalf("short part: want ErrPartSizeMismatch got %v", err)
}
// 失败不留 parts/B4: 截断分片不被 Status 报告为已接收
recv, err := u.Status(ctx, uid)
if err != nil || len(recv) != 0 {
t.Fatalf("failed parts must not be received: %v err=%v", recv, err)
}
// 未知/非法 uid
if _, err := u.Status(ctx, "deadbeefdeadbeefdeadbeefdeadbeef"); !errors.Is(err, ports.ErrNotFound) {
t.Fatalf("unknown uid: want ErrNotFound got %v", err)
}
if _, err := u.Status(ctx, "zzz"); !errors.Is(err, ErrBadUploadID) {
t.Fatalf("bad uid: want ErrBadUploadID got %v", err)
}
}
func TestCompleteHappyPathAndCleanup(t *testing.T) {
u := newU(t)
ctx := context.Background()
root := filepath.Join(u.booksDir, "lib")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
content := bytes.Repeat([]byte("調教開關第二季!"), 300) // ~6.3KB multibyte
size := int64(len(content))
const chunk = int64(2000)
uid := mustInit(t, u, 7, "調教開關:第二季.zip", size, chunk)
n := int((size + chunk - 1) / chunk)
// 乱序上传:索引顺序 1,2,...,n-1,0 —— 覆盖所有分片且非递增
for step := 1; step <= n; step++ {
idx := int64(step % n)
lo := idx * chunk
hi := min(lo+chunk, size)
if err := u.PutPart(ctx, uid, idx, bytes.NewReader(content[lo:hi]), 0); err != nil {
t.Fatalf("PutPart %d: %v", idx, err)
}
}
// 缺片 → ErrIncomplete(先删一片验证)
part0 := filepath.Join(u.uploadDir(uid), "parts", "0")
saved, _ := os.ReadFile(part0)
os.Remove(part0)
if _, err := u.Complete(ctx, uid, root); !errors.Is(err, ports.ErrIncomplete) {
t.Fatalf("missing part: want ErrIncomplete got %v", err)
}
os.WriteFile(part0, saved, 0o644)
rel, err := u.Complete(ctx, uid, root)
if err != nil {
t.Fatalf("Complete: %v", err)
}
if rel != "調教開關:第二季.zip" {
t.Fatalf("rel path: %q", rel)
}
got, err := os.ReadFile(filepath.Join(root, rel))
if err != nil || !bytes.Equal(got, content) {
t.Fatalf("assembled content wrong: err=%v", err)
}
if _, err := os.Stat(u.uploadDir(uid)); !os.IsNotExist(err) {
t.Fatal("session dir must be removed after complete")
}
}
func TestCompleteUniquePathCollision(t *testing.T) {
u := newU(t)
ctx := context.Background()
root := filepath.Join(u.booksDir, "lib")
os.MkdirAll(root, 0o755)
os.WriteFile(filepath.Join(root, "dup.cbz"), []byte("existing"), 0o644)
uid := mustInit(t, u, 1, "dup.cbz", 4, 4)
if err := u.PutPart(ctx, uid, 0, bytes.NewReader([]byte("new!")), 0); err != nil {
t.Fatal(err)
}
rel, err := u.Complete(ctx, uid, root)
if err != nil {
t.Fatalf("Complete: %v", err)
}
if rel != "dup (1).cbz" {
t.Fatalf("collision must add suffix, got %q", rel)
}
}
func TestUniquePathTraversalRejected(t *testing.T) {
u := newU(t)
root := filepath.Join(u.booksDir, "lib")
if _, err := u.UniquePath(root, "../escape.cbz"); !errors.Is(err, os.ErrInvalid) {
t.Fatalf("traversal: want os.ErrInvalid got %v", err)
}
p, err := u.UniquePath(root, "ok.cbz")
if err != nil || p != filepath.Join(root, "ok.cbz") {
t.Fatalf("valid name: %q %v", p, err)
}
}
func TestSweepRemovesOnlyExpired(t *testing.T) {
u := newU(t)
ctx := context.Background()
fresh := mustInit(t, u, 1, "fresh.cbz", 10, 10)
stale := mustInit(t, u, 1, "stale.cbz", 10, 10)
// 把 stale 会话 mtime 拨到 TTL 之前
old := time.Now().Add(-uploadSessTTL - time.Hour)
dir := u.uploadDir(stale)
if err := os.Chtimes(filepath.Join(dir, "meta.json"), old, old); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(dir, old, old); err != nil {
t.Fatal(err)
}
if err := u.Sweep(ctx); err != nil {
t.Fatalf("Sweep: %v", err)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Fatal("stale session must be swept")
}
if _, err := os.Stat(u.uploadDir(fresh)); err != nil {
t.Fatalf("fresh session must survive: %v", err)
}
// 空目录/不存在 base 都不报错
os.RemoveAll(filepath.Join(u.booksDir, uploadSessionIn))
if err := u.Sweep(ctx); err != nil {
t.Fatalf("Sweep on missing base: %v", err)
}
}
func TestLibraryID(t *testing.T) {
u := newU(t)
ctx := context.Background()
uid := mustInit(t, u, 42, "lib.cbz", 10, 10)
id, err := u.LibraryID(ctx, uid)
if err != nil || id != 42 {
t.Fatalf("LibraryID: %d %v", id, err)
}
if _, err := u.LibraryID(ctx, "deadbeefdeadbeefdeadbeefdeadbeef"); !errors.Is(err, ports.ErrNotFound) {
t.Fatalf("unknown uid: want ErrNotFound got %v", err)
}
}
+57 -2
View File
@@ -10,23 +10,78 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Added / 新增
- API: resumable chunked upload protocol for large files — `POST /api/libraries/:id/upload/init` (fingerprint-derived deterministic `uploadId`, rejects totals over `UPLOAD_MAX_MB` with `413 too_large`), `PUT /api/uploads/:uid/parts/:index` (parts ≤ 32MB), `GET /api/uploads/:uid` (received parts, for resume), `POST /api/uploads/:uid/complete` (assemble + atomic land, same path contract as single-POST upload). Sessions persist under `BOOKS_DIR/.uploads/` with a periodic sweep. `UPLOAD_MAX_MB` is now wired through both compose stacks/`.env`; `.env.example` sets 2048 and drops `NGINX_CLIENT_MAX_BODY_SIZE` to 32m (nginx only ever sees one chunk).
- API:新增大文件可续传分片上传协议——`POST /api/libraries/:id/upload/init`(按指纹派生确定性 `uploadId`,总量超 `UPLOAD_MAX_MB` 返回 `413 too_large`)、`PUT /api/uploads/:uid/parts/:index`(单片 ≤32MB)、`GET /api/uploads/:uid`(查询已传分片以续传)、`POST /api/uploads/:uid/complete`(拼接后原子落盘,返回与单发上传一致的 `path`)。会话存于 `BOOKS_DIR/.uploads/`,定期清理。`UPLOAD_MAX_MB` 已接入两份 compose/`.env`;`.env.example` 调至 2048 并将 `NGINX_CLIENT_MAX_BODY_SIZE` 降为 32m(nginx 只见单个分片)。
- API: per-user bookmarks — `GET/POST /api/books/:id/bookmarks` (locator+percent snapshot with optional ≤500-char note; list ordered by percent) and `PATCH/DELETE /api/bookmarks/:id`; not-yours uniformly 404. New `bookmarks` table keyed like progress, cleaned up with the user (no cascade on book delete, same precedent).
- API:新增按用户隔离的书签——`GET/POST /api/books/:id/bookmarks`(存当前 locator+percent,备注可选、≤500 字,列表按进度升序)与 `PATCH/DELETE /api/bookmarks/:id`;不属于自己的一律 404。新 `bookmarks` 表与进度同款定位键,随用户删除而清(删书不级联,沿用既有先例)。
- API: CBZ page indexing now skips macOS packaging junk (`__MACOSX/…` and `._*` AppleDouble files), which used to land in the page list as ~163-byte black "pages"; `GET /api/books/:id/pages` additionally returns `chapters:[{title,start}]` derived from the archive's folder structure (e.g. 第1話…), so per-folder comics expose their real organization.
- API:CBZ 页索引现会跳过 macOS 打包垃圾(`__MACOSX/…` 与 `._*` 资源叉文件),此前它们以 ~163 字节黑页混入页列表;`GET /api/books/:id/pages` 新增 `chapters:[{title,start}]`,按压缩包内目录结构(如 第1話…)给出真实章节。
- API: resumable chunked upload protocol for large files — `POST /api/libraries/:id/upload/init` (fingerprint-derived deterministic `uploadId`, rejects totals over `UPLOAD_MAX_MB` with `413 too_large`), `PUT /api/uploads/:uid/parts/:index` (parts ≤ 32MB), `GET /api/uploads/:uid` (received parts, for resume), `POST /api/uploads/:uid/complete` (assemble + atomic land, same path contract as single-POST upload). Sessions persist under `BOOKS_DIR/.uploads/` with 24h opportunistic sweep. `UPLOAD_MAX_MB` is now wired through both compose stacks/`.env`; `.env.example` sets 2048 and drops `NGINX_CLIENT_MAX_BODY_SIZE` to 32m (nginx only ever sees one chunk).
- API: 新增大文件可续传分片上传协议——`POST /api/libraries/:id/upload/init`(按指纹派生确定性 `uploadId`,总量超 `UPLOAD_MAX_MB` 返回 `413 too_large`)、`PUT /api/uploads/:uid/parts/:index`(单片 ≤32MB)、`GET /api/uploads/:uid`(查询已传分片以续传)、`POST /api/uploads/:uid/complete`(拼接后原子落盘,返回与单发上传一致的 `path`)。会话存于 `BOOKS_DIR/.uploads/`,超 24h 顺手清理。`UPLOAD_MAX_MB` 已接入两份 compose/`.env`;`.env.example` 调至 2048 并将 `NGINX_CLIENT_MAX_BODY_SIZE` 降为 32m(nginx 只见单个分片)。
- Tests: router contract test (`TestRouterContract`) pins the full route table — any added, removed or renamed route fails the test until the expectation is updated deliberately.
- 测试:新增路由契约测试(`TestRouterContract`),锁定全量路由表——任何路由的增删改名都会使该测试失败,必须显式更新预期。
- Tests: hand-written in-memory fakes (`internal/ports/portsfake`) cover all port interfaces, enabling handler unit tests with no PG or Redis. Error semantics mirror the real store (`pgx.ErrNoRows`, `store.ErrLastAdmin`, `*pgconn.PgError{Code:23505}`), so the fakes exercise the same 404/409/400 branches as the database.
- 测试:新增手写内存 fake(`internal/ports/portsfake`),覆盖全部 port 接口,使 handler 单测无需 PG/Redis 即可运行。错误语义与真实 store 一致(`pgx.ErrNoRows`、`store.ErrLastAdmin`、`*pgconn.PgError{Code:23505}`),因此 fake 走到的是与真库相同的 404/409/400 分支。
- CI workflow (`.github/workflows/ci.yml`) compatible with both GitHub Actions and Gitea Actions.
- CI 工作流(`.github/workflows/ci.yml`),兼容 GitHub Actions 和 Gitea Actions。
### Changed / 变更
- Backend restructured around hexagonal ports: HTTP handlers now depend only on small consumer-side interfaces (`internal/ports`) instead of concrete `*store.Store` / `*redispkg.R` / `*scanner.Scanner`. Domain logic moved out of handlers into `internal/media` (cover/page extraction, page index cache, atomic cache writes) and `internal/upload` (chunked session lifecycle). `cmd/webui/main.go` is the composition root; `api.NewRouter` accepts pure interfaces.
- 后端按六边形端口重构:HTTP handler 现在只依赖 `internal/ports` 中的小口径消费端接口,不再直接持有 `*store.Store` / `*redispkg.R` / `*scanner.Scanner` 等具体类型。域逻辑从 handler 下沉到 `internal/media`(封面/页抽取、页索引缓存、缓存原子写)与 `internal/upload`(分片会话生命周期)。`cmd/webui/main.go` 作为装配根,`api.NewRouter` 只收接口。
- Upload session sweep moved off the request path onto the scanner's ticker cycle (B16), so `POST /upload/init` no longer pays for a directory walk.
- 上传会话清理从请求路径移到扫描器的定时周期(B16),`POST /upload/init` 不再顺带付出一次目录遍历的开销。
- Add ordered migration system with `schema_migrations` tracking and pg advisory lock for safe multi-replica schema evolution. Existing databases are auto-baselined. To change the schema, add a new `NNNN_description.sql` file under `backend/internal/db/migrations/`; never modify an already-applied file. No down migrations — rollback via backup, fix-forward.
- 新增有序迁移系统,通过 `schema_migrations` 表和 pg advisory lock 实现安全的多副本 schema 演进,已有数据库自动基线化。修改 schema 时在 `backend/internal/db/migrations/` 下新增 `NNNN_description.sql`,已应用的文件不可修改。不支持 down 迁移——回滚靠备份,fix-forward。
- Scanner: an image-list-less archive (`.zip`/`.cbz` with no page images — video packs, document dumps) is now recorded as `state=error` ("no images in archive") instead of registering as an empty CBZ with a blank reader.
- 扫描器:不含任何图片条目的 `.zip`/`.cbz`(视频包、文档包)现记录为 `state=error`("no images in archive"),不再注册成空 CBZ 留下一个白板阅读器。
- API: upload over `UPLOAD_MAX_MB` now returns `413 too_large` with the limit in the message; previously the size abort was misreported as `400 bad_request "multipart field 'file' required"`.
- API:超过 `UPLOAD_MAX_MB` 的上传现在返回 `413 too_large` 并在消息中带上限额;此前体积超限被误报为 `400 bad_request "multipart field 'file' required"`。
- API: `POST /api/libraries` now takes only `{name}`; `root_path` is generated server-side as `BOOKS_DIR/<sanitized name>` (no client-supplied paths, validated at creation).
- API:`POST /api/libraries` 只需 `{name}`;`root_path` 由服务端生成为 `BOOKS_DIR/<清洗后的库名>`(不再接受客户端指定路径,创建时即校验)。
- Repo structure conformed to `AGENTS.md`: `web/` renamed to `frontend/`; backend HTTP layer moved from `internal/api` to `cmd/webui/{api,handlers}` (`cmd/server` → `cmd/webui`); README/CHANGELOGs relocated under `docs/` (`README_zh.md` added as Chinese mirror); module-level `.gitignore`s added (`backend/`, `deploy/`); stray root `library/` removed (book files live in `deploy/api/storage/`); debug binaries untracked.
- Docs: `docs/README.md` is now the English primary; previous Chinese README mirrored to `docs/README_zh.md`.
### Fixed / 修复
- Rate limiter `IncrWindow` uses atomic Lua script for INCR+EXPIRE, preventing permanent IP lockout on EXPIRE failure (B1).
- 限流器 `IncrWindow` 改用 Lua 脚本原子执行 INCR+EXPIRE,防止 EXPIRE 失败导致 IP 永久锁定(B1)。
- Distributed lock `Lock` handles `rand.Read` failure by degrading to no-lock instead of using a zero token (B2).
- 分布式锁 `Lock` 在 `rand.Read` 失败时降级为无锁模式,而非使用全零 token(B2)。
- Lock unlock uses `context.WithoutCancel` to survive caller cancellation (B3).
- 锁的解锁改用 `context.WithoutCancel`,在调用方上下文取消后仍能正常释放(B3)。
- Upload part writes to `.tmp` then renames, preventing truncated parts from being reported as received (B4).
- 分片上传先写 `.tmp` 再 rename,防止崩溃截断的分片被误报为已接收(B4)。
- `DeleteUser` last-admin check is now transactional, eliminating TOCTOU race (B5).
- `DeleteUser` 的最后管理员检查改为事务内执行,消除 TOCTOU 竞态(B5)。
- Single-file upload `io.Copy` errors other than `MaxBytesError` return 500 instead of 413 (B6).
- 单文件上传中非 `MaxBytesError` 的 `io.Copy` 错误返回 500 而非 413(B6)。
- `/auth/me` distinguishes `no rows` (401) from database errors (503) (B7).
- `/auth/me` 区分无记录(401)和数据库错误(503)(B7)。
- Library creation rejects reserved names (`cache`, `.uploads`) with `400 reserved_name` (B8).
- 创建书库时拒绝保留名(`cache`、`.uploads`),返回 `400 reserved_name`(B8)。
- Scanner lock auto-renews every TTL/2 during long scans; per-library single-flight prevents concurrent scans (B9).
- 扫描锁每 TTL/2 自动续期;库级 single-flight 防止并发扫描(B9)。
- Scanner `SetBookState` errors are now logged instead of silently discarded (B10).
- 扫描器 `SetBookState` 的错误现在会记录日志而非静默丢弃(B10)。
- Cover write errors fully checked; orphan `.tmp` files cleaned only on failure (B11).
- 封面写入错误全部检查;孤儿 `.tmp` 文件仅在失败路径清理(B11)。
- Upload handler retries on `O_EXCL` collision for concurrent same-name uploads (B12).
- 上传处理器在 `O_EXCL` 冲突时重试,处理并发同名上传(B12)。
- Bookmark methods check `err` before `RowsAffected` to avoid invalid reads on query failure (B13).
- 书签方法先检查 `err` 再读 `RowsAffected`,避免查询失败时的无效读取(B13)。
- Serve goroutine `log.Fatalf` replaced with channel-based shutdown to preserve graceful teardown (B14).
- 服务 goroutine 中的 `log.Fatalf` 改为 channel 通知方式,确保优雅关停流程不被绕过(B14)。
- `DATABASE_URL` is now validated at startup (required, parseable); empty `REDIS_URL` logs a clear "redis disabled" message (B15).
- `DATABASE_URL` 在启动时校验(必填、可解析);空 `REDIS_URL` 记录明确的 "redis disabled" 日志(B15)。
- `scripts/smoke.sh` aligned with current API contract, removed ignored `root_path` field (B17).
- `scripts/smoke.sh` 对齐当前 API 契约,移除被忽略的 `root_path` 字段(B17)。
+44 -7
View File
@@ -8,8 +8,40 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
## [Unreleased]
### Changed / 变更
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
### Added / 新增
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support.
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card.
- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
- E2E test suite (Playwright + axe-core) covering login→shelf→CBZ reading→bookmark→logout and an admin create-library/scan smoke, run against the dev compose stack via `npm run e2e` (manual pre-release gate); critical/serious a11y violations fail the run.
- 新增 e2e 测试套件(Playwright + axe-core):覆盖 登录→书架→CBZ 阅读→书签→退出 主流程与 admin 建库/扫描冒烟,针对 dev compose 栈以 `npm run e2e` 手动门禁运行;critical/serious 级 a11y 违规会使测试失败。
- Developer tooling: ESLint (flat config, typescript-eslint + react-hooks + jsx-a11y) and Prettier are now part of `npm run check` and CI; the whole codebase passes with zero eslint errors.
- 开发工具链:ESLint(flat config,typescript-eslint + react-hooks + jsx-a11y)与 Prettier 纳入 `npm run check` 和 CI;全库 0 eslint error。
- Lint toolchain note: TypeScript runs side-by-side (`typescript` = TS 6.0.2 JS API for tooling, `@typescript/native` = native 7.0.2 for `tsc`) because typescript-eslint does not support TS 7 yet; do not alias `typescript` to 7.x until typescript-eslint ships TS 7 support.
- 工具链备注:TypeScript 双轨并存(`typescript` = TS 6.0.2 JS API 供 lint 工具链,`@typescript/native` = native 7.0.2 提供 `tsc`),因 typescript-eslint 暂不支持 TS 7;待其支持前勿把 `typescript` 别名改回 7.x。
- CBZ reader is now chapter-scoped: a chapter's images flow as one continuous strip and scrolling stops at an 本章完 · 下一章 card (only chapter buttons / TOC / slider / bookmarks cross the boundary); a 连读 toggle makes scrolling flow across chapters, and a configurable 0–3-chapter prefetch pre-mounts upcoming chapters for instant switching — the old 连读/整页/适高 mode cycle is gone.
- CBZ 阅读器改为以章为单位:一章图片是一段连续长卷,滚动止于「本章完 · 下一章」卡片(跨章只能靠章节按钮/目录/滑条/书签);「连读」开关让滑动贯穿章节,预读 0–3 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
@@ -31,6 +63,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Changed / 变更
- Admin polish: 库管理 is now a table styled like 用户管理 (scan/upload per row, drag-to-row upload kept); both admin pages moved out of the sidebar and bottom tabs into a 系统管理 group under the avatar menu; admins can upload files straight from a library's shelf view via a new 上传文件 button next to 扫描此库.
- 后台入口统一:库管理改为用户管理同款表格(行内扫描/上传,仍支持拖文件到行);两个管理页从侧边栏与移动端底 Tab 收进头像菜单的「系统管理」分组;书架进入某个库时,管理员可在「扫描此库」旁直接「上传文件」。
- Reader chrome is no longer text-only: lucide icons accompany 导航/上一章/下一章/翻页/抽屉选项卡/书签操作, while 连读 and 预读 (now a 0–3 slider) collapse into a 更多 menu in the CBZ bar.
- 阅读器工具条告别纯文字:导航/上一章/下一章/翻页/抽屉选项卡/书签操作均配 lucide 图标;连读与预读(改为 0–3 滑条)收进 CBZ 工具条的「更多」菜单。
@@ -48,6 +83,9 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
- Whole UI restyled to a warm library palette (stone surfaces + amber accents, replacing cool zinc + emerald): warmer dark chrome, amber primary buttons/links, hover/press feedback on shelf cards (cover lift + glow), reader header with format badge and blur, pill-style CBZ page indicator, EPUB spread centered like a book page.
- 全站换为暖色「书房」配色(stone 深灰面 + amber 琥珀强调,替代冷色 zinc + emerald):书架封面悬停上浮高亮、阅读器顶栏带格式徽章与毛玻璃、CBZ 页码改为悬浮胶囊、EPUB 页面居中成书卷感,主按钮/链接统一琥珀色并补齐悬停/焦点反馈。
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
### Removed / 移除
- The admin 删除 button is gone from the reader header: deleting books now lives only in the shelf cover-card menu (with a confirm dialog), so a destructive tap can't happen one step away from the page-turn zone while reading.
@@ -55,6 +93,12 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
### Fixed / 修复
- Accessibility fixes: login screen muted text meets WCAG AA contrast in light theme (muted-foreground token darkened); the CBZ reading pane is now keyboard-scrollable (scroll container is focusable).
- 无障碍修复:登录页弱化文本在亮色主题下达到 WCAG AA 对比度(muted-foreground 色值加深);CBZ 阅读面板现在可用键盘滚动(滚动容器可聚焦)。
- CBZ reader no longer snaps to the top (or visibly bounces) when scrolling or dragging the scrollbar near the end of a long chapter: re-estimating every unmeasured page from each newly measured page made the scroll geometry oscillate on books with mixed page orientations (and the correction was computed from a stale anchor), so the estimator now learns once from the first measured page and then stays put; after a geometry change the viewport is re-anchored absolutely (keep the current page under the same screen spot) instead of by a delta, and while the scrollbar is being dragged the reader doesn't write scrollTop at all, leaving the browser in sole control.
- CBZ 阅读器在长章节末尾滚动或拖滚动条时不再出现"到底后被重置到上方"的跳变:此前每量完一页就用它重估所有未量页的高度,横竖版混排的书里估高来回翻转、滚动几何剧烈呼吸,且补偿量基于已过期的 anchor 计算;现改为估高只从首个实测页学习一次、之后保持稳定,几何变化后按"当前页钉回原屏幕位置"绝对重锚(不再用增量补偿),拖拽滚动条期间阅读器完全不写 scrollTop,避免与浏览器抢滚动控制权。
- Library upload now reports the real file count: the success toast read the live `FileList` after the file input had been reset (always 0, e.g. "已上传 0 个文件"), making successful uploads look broken while waiting for the scanner; the list is snapshotted before uploading.
- 库管理上传完成提示不再恒为「已上传 0 个文件」:此前成功提示在清空 file input 之后读取其活引用 FileList 的长度(始终为 0),上传明明成功却像没生效;现在在上传开始前对文件列表做快照,计数与遍历都用快照。
@@ -67,10 +111,3 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
- GBK/GB2312/GB18030 或 UTF-16 编码的 .txt/.md(网文导出常见)不再乱码:阅读器先按 UTF-8 严格解码,失败自动回退 GB18030,UTF-16 靠 BOM 识别。
- Reading a large .txt no longer freezes the browser: the text is split into chapters (「第X章」/Chapter X/序章… markers, pseudo-sections for unmarked files) and rendered one chapter at a time with a chapter selector and prev/next navigation; 「第X卷/部」 are volume groupings (dropdown optgroups), not chapter breaks, and books with only volumes split by volume; reading progress restores to the saved chapter.
- 阅读大 txt 不再卡死浏览器:正文按章节切分(识别「第X章」/Chapter X/序章等标题,无标记的按段落切成小节),每次只渲染一章,并提供章节目录选择与上一章/下一章导航;「第X卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。
### Changed / 变更
- Creating a library on the admin Libraries page no longer asks for a server-side absolute path; just a name (the directory lands at `BOOKS_DIR/<name>` automatically).
- 库管理页建库不再要求填写服务端绝对路径,只需库名(目录自动落在 `BOOKS_DIR/<库名>` 下)。
_No WebUI user-visible changes in this release; the structural refactor (`web/` → `frontend/`) does not affect the served app._
+33 -2
View File
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
`-p 1` is required: integration tests share one PG database and each clears tables with `DELETE FROM ...` — running in parallel deletes each other's data and fails randomly. Bring the dev stack down with `down` (not `rm`), or the anonymous node_modules volume becomes an orphan. Tests that need PG/Redis skip automatically when absent; Redis downtime doesn't break functionality (the whole chain degrades to miss/passthrough, see spec §9).
Frontend gate: `cd frontend && npm run check` (tsc + vitest + vite build).
Frontend gate: `cd frontend && npm run check` (tsc + eslint + prettier + vitest + vite build). E2E is a separate manual pre-release gate against the dev stack: `cd frontend && npm run e2e` (requires `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`, or source `deploy/.env`; one-off browser install inside the web container: `npx playwright install --with-deps chromium`). Bundle report: `npm run analyze` → `dist-stats/stats.html`.
## Old-volume migration (one-off, upgrading from the previous deploy layout)
@@ -68,7 +68,38 @@ Book files: move the contents of the old host `./library/` into `deploy/api/stor
## Read this before changing the schema
`db.Migrate` only runs the `CREATE TABLE IF NOT EXISTS` statements of `schema.sql` — column adds/changes **do not take effect** on existing databases. Before any column change, introduce a `schema_migrations` version table + ordered migrations, otherwise old deployments silently run on the old shape.
Schema changes go through the ordered migration system in `backend/internal/db/migrations/`:
1. Create a new file: `NNNN_description.sql` (four-digit sequence number, lowercase snake_case).
2. Never modify an already-applied migration file — they are immutable.
3. No down migrations: rollback via database backup, fix-forward.
4. Existing databases are auto-baselined on first startup (0001 marked applied without re-running DDL).
5. Migrations run with `pg_advisory_lock` so `--scale api=N` replicas serialize safely.
Local gate before each batch merge: `go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...` with dev PG+Redis running.
## Backend structure
The backend is organized around consumer-side port interfaces (hexagonal style):
- `cmd/webui` — binary entry point and composition root: `main.go` builds concrete implementations (`store.Store`, `redispkg.R`, `scanner.Scanner`, `media.M`, `upload.U`) and hands them to `api.NewRouter`, which only accepts the port interfaces.
- `cmd/webui/handlers` — HTTP layer: request binding, auth/authz, error → status mapping. No SQL, no archive/file logic.
- `internal/ports` — the small interfaces handlers depend on (`UserStore`, `LibraryStore`, `BookStore`, `ProgressStore`, `BookmarkStore`, `RateLimiter`, `Scanner`, `Media`, `UploadSessions`) plus shared sentinel errors. Interfaces live on the consumer side, implementations satisfy them.
- `internal/ports/portsfake` — hand-written in-memory fakes for every port, with error semantics mirroring the real store (`pgx.ErrNoRows`, `ErrLastAdmin`, PgError 23505). Handler unit tests run against these with no PG/Redis.
- `internal/media` — cover/page extraction, page-index cache (Redis-backed), atomic cache writes.
- `internal/upload` — chunked upload session lifecycle (init/part/status/complete/sweep).
- `internal/store` — all SQL, one place.
- `internal/scanner` — library walk, ingest (add/update/delete in one pass), sweep riding the scan ticker.
- `internal/bookfile` — shared file utilities (`SafeName`, `Contains`, `Hash`, `FormatFromExt`, cache dir layout).
Testing is two-tiered: integration tests hit a real PG+Redis via the full router (`handlers/*_test.go` with `setupAPI`), unit tests hit the same router with `portsfake` injected (`handlers/*_unit_test.go`). The route table itself is pinned by `TestRouterContract` in `cmd/webui/api`.
## CI
- Workflow: `.github/workflows/ci.yml` (standard GitHub Actions syntax, Gitea Actions compatible).
- **Gitea**: register an `act_runner` instance, enable Actions in repo settings. Works out of the box.
- **GitHub**: works out of the box.
- Until a runner is registered, run the local gate manually before merging.
## PWA
+33 -2
View File
@@ -48,7 +48,7 @@ go test -p 1 -count=1 ./...
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
前端门槛:`cd frontend && npm run check`(tsc + vitest + vite build)。
前端门槛:`cd frontend && npm run check`(tsc + eslint + prettier + vitest + vite build)。e2e 是独立的手动发布门禁,针对 dev 栈运行:`cd frontend && npm run e2e`(需 `E2E_ADMIN_USER`/`E2E_ADMIN_PASSWORD`,或 source `deploy/.env`;浏览器一次性安装于 web 容器内:`npx playwright install --with-deps chromium`)。体积报告:`npm run analyze` → `dist-stats/stats.html`。
## 旧卷迁移(一次性,升级自上一版部署)
@@ -68,7 +68,38 @@ docker volume rm book-comic-library_pgdata book-comic-library_cache
## 改 schema 前必读
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
Schema 变更通过 `backend/internal/db/migrations/` 中的有序迁移系统执行:
1. 新建文件:`NNNN_description.sql`(四位序号,小写下划线命名)。
2. 已应用的迁移文件不可修改——它们是不可变的。
3. 不支持 down 迁移:回滚靠数据库备份,fix-forward。
4. 已有数据库在首次启动时自动基线化(0001 标记为已应用,不重跑 DDL)。
5. 迁移使用 `pg_advisory_lock` 确保 `--scale api=N` 副本串行执行。
每批合入前的本地门禁:`go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...`(需启动 dev PG+Redis)。
## 后端结构
后端按消费端接口组织(六边形风格):
- `cmd/webui` —— 二进制入口与装配根:`main.go` 构造具体实现(`store.Store`、`redispkg.R`、`scanner.Scanner`、`media.M`、`upload.U`)并交给 `api.NewRouter`,后者只接受 port 接口。
- `cmd/webui/handlers` —— HTTP 层:参数绑定、认证/鉴权、错误→状态码映射。没有 SQL,没有压缩包/文件逻辑。
- `internal/ports` —— handlers 依赖的小口径接口(`UserStore`、`LibraryStore`、`BookStore`、`ProgressStore`、`BookmarkStore`、`RateLimiter`、`Scanner`、`Media`、`UploadSessions`)与共享 sentinel 错误。接口定义在消费端,实现方来满足它们。
- `internal/ports/portsfake` —— 全部 port 的手写内存 fake,错误语义与真实 store 一致(`pgx.ErrNoRows`、`ErrLastAdmin`、PgError 23505)。handler 单测无需 PG/Redis。
- `internal/media` —— 封面/页抽取、页索引缓存(Redis)、缓存原子写。
- `internal/upload` —— 分片上传会话生命周期(init/part/status/complete/sweep)。
- `internal/store` —— 所有 SQL,集中一处。
- `internal/scanner` —— 书库遍历、ingest(增/改/删一趟完成)、会话清扫搭扫描 ticker 顺风车。
- `internal/bookfile` —— 共享文件工具(`SafeName`、`Contains`、`Hash`、`FormatFromExt`、缓存目录布局)。
测试分两层:集成测试走真实 PG+Redis、过完整 router(`handlers/*_test.go` 的 `setupAPI`);单测注入 `portsfake`、过同一个 router(`handlers/*_unit_test.go`)。路由表本身由 `cmd/webui/api` 的 `TestRouterContract` 钉死。
## CI
- 工作流:`.github/workflows/ci.yml`(标准 GitHub Actions 语法,兼容 Gitea Actions)。
- **Gitea**:注册 `act_runner` 实例,在仓库设置中启用 Actions,开箱即用。
- **GitHub**:开箱即用。
- Runner 注册前,合入前手动执行本地门禁。
## PWA
+59
View File
@@ -0,0 +1,59 @@
# 前端 bundle 审视(2026-09) / Frontend bundle review
> 由 `cd frontend && npm run analyze` 生成 `dist-stats/stats.html` 后人工审视得出;本期只记录结论,不实施优化(spec ④ S4)。
> Generated from `dist-stats/stats.html` (`npm run analyze`); findings only — no optimization in this batch (spec ④ S4).
## 现状 / Current state
实测基线(`frontend/dist/`,rollup/rolldown 构建产物):总计约 2.6 MB(assets 2,609,869 B;`du -sh` 显示 2.6M,含 PWA precache 全部条目)。每 chunk 构成如下。**注意两套口径**:chunk 级 `stat` 为 minified 实测产物大小(与 `ls dist/assets` 一致);`备注` 列中的依赖级数字为**源码级(未压缩,rollup `renderedLength`)口径**,仅用于依赖之间的相对占比,**不可与 stat/minified 字节数相加或比对**。gzip/brotli 来自 visualizer 输出;总注:gzip 列为 visualizer 容器口径,与宿主机 `gzip -c | wc -c` 在多个文件上存在 ≤2% 的小数位差异(如 index 145.3 vs 143.8 kB、TextReader 27.0 vs 26.7 kB),属统计口径差异。
Measured baseline (`frontend/dist/`): ~2.6 MB total (assets 2,609,869 B; `du -sh` 2.6M). Composition per chunk below. **Two calibers apply**: chunk-level `stat` is the measured minified artifact size (matches `ls dist/assets`); dependency-level figures in the `notes` column are **source-level (uncompressed rollup `renderedLength`)** and are only for relative shares among dependencies — **they must not be summed with or compared against stat/minified byte counts**. gzip/brotli come from the visualizer output; note: the gzip column uses the visualizer's in-container figure, which differs from host-side `gzip -c | wc -c` by ≤2% on several files (e.g. index 145.3 vs 143.8 kB, TextReader 27.0 vs 26.7 kB) — a statistics-caliber difference.
| chunk | 入口/来源 | stat | gzip | brotli | 备注 |
| --- | --- | --- | --- | --- | --- |
| `index-CGr3zft8.js` | 主入口(`src/main.tsx`,shelf/login/admin + react-dom/router/query/radix) | 462.6 kB | 145.3 kB | 122.3 kB | 依赖级(源码级口径,相对占比):react-dom 459.8 + react-router 94.9 + tailwind-merge 56 + radix-ui 各子包共 ~131 + lucide-react(34 个图标) 17.3(gzip 10.1)+ (src) 83.9 kB |
| `pdf.worker-C0DQFlrB.js` | `PdfReader.tsx` worker(`pdfjs-dist/build/pdf.worker.mjs`,Vite worker 语法独立产物) | 1,189.3 kB | 367.0 kB | ~ | 独立文件,不进 index 主包 |
| `PdfReader-Bv6vuw8i.js` | `pages/Reader.tsx` → `lazy(() => import(...))` | 434.4 kB | 130.5 kB | 137.9 kB | pdfjs-dist 主库 764.3 kB(源码级口径,本 chunk 最大依赖;brotli 135.4 kB) |
| `src-BNcXNGhn.js` | 共享 chunk:EpubReader 的 `await import("epubjs")` 链 | 345.4 kB | 103.6 kB | 135.5 kB | 依赖级(源码级口径):epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB + marks-pane/path-webpack/lodash 等 |
| `TextReader-CmvYmFNB.js` | `lazy()`(txt 与 md 共用) | 79.3 kB | 27.0 kB | 30.1 kB | 依赖级(源码级口径):marked 53.7 + dompurify 59.7 kB(gzip 14.2/14.5 kB) |
| `CbzReader-BEFlX6jt.js` | `lazy()` | 12.6 kB | 5.1 kB | 6.2 kB | 仅 (src) 21.1 kB(源码级口径)里的渲染逻辑,jszip 共享 chunk 已含 |
| `useProgress-BHPrULZV.js` | `lib/useProgress`(多 reader 共享) | 7.0 kB | 2.8 kB | 4.7 kB | |
| `EpubReader-BIpid_pd.js` | `lazy()` | 3.1 kB | 1.5 kB | 1.5 kB | 仅薄壳;epubjs 全链在 `src-BNcXNGhn.js` |
| `workbox-window.prod.es5-Bd17z0YL.js` | vite-plugin-pwa(registerSW) | 5.7 kB | 2.2 kB | 2.3 kB | |
| `rolldown-runtime-Dd_uD5pT.js` | 运行时 | 1.1 kB | ~0.9 kB | 0.8 kB | index.html 里 `<link rel="modulepreload">` |
| `index-C4Rgqjhl.css` | Tailwind v4 产物 | 69.4 kB | 12.1 kB | ~ | |
- 五个阅读器(cbz/txt/md/pdf/epub)均已通过 `pages/Reader.tsx` 的 `lazy()` 按需加载:是(实测确认——`dist/assets` 有 Cbz/Pdf/Text/Epub 四个独立 reader chunk;txt/md 共用 TextReader 一个 chunk;pdf 另有独立 pdf.worker 文件)。
Yes (verified): `dist/assets` contains separate chunks for Cbz/Pdf/Text/Epub readers; txt and md share the TextReader chunk; pdf additionally has a separate pdf.worker file.
- PWA precache(`workbox.globPatterns`)当前包含哪些大文件:generateSW 报告 "precache 15 entries (2549.92 KiB)",实测把全部 11 个 JS/CSS 都打进 precache,15 条 = 11 JS/CSS + index.html + icon.svg×2(icon.svg 在清单中出现两次)+ manifest.webmanifest;其中最大的五个:`pdf.worker` 1,189.3 kB、`index` 主包 462.6 kB、`PdfReader` 434.4 kB、`src-BNcXNGhn.js`(epubjs 链)345.4 kB、`TextReader` 79.3 kB。`maximumFileSizeToCacheInBytes` 是 generateSW 构建配置项(`vite.config.ts` 未设置,走默认 2 MiB),非 `sw.js` 内字段——默认值下 1.19 MB 的 pdf.worker 未被排除、仍在 precache 内。
The precache (reported as "15 entries, 2549.92 KiB") includes all 11 JS/CSS files; the 15 entries = 11 JS/CSS + index.html + icon.svg×2 (icon.svg appears twice in the manifest) + manifest.webmanifest; the five largest are pdf.worker 1,189.3 kB, index 462.6 kB, PdfReader 434.4 kB, src-BNcXNGhn.js (epubjs chain) 345.4 kB, TextReader 79.3 kB. `maximumFileSizeToCacheInBytes` is a generateSW build-time option (not set in `vite.config.ts`, so the default 2 MiB applies), not a field inside `sw.js` — under the default, the 1.19 MB pdf.worker is not excluded and stays in the precache.
## 可优化点 / Optimization backlog
| # | 问题 | 证据(chunk/体积) | 建议动作 | 归属 |
| --- | --- | --- | --- | --- |
| 1 | PWA precache 把全站 JS 全量缓存:pdf.worker(1.19 MB)+PdfReader(434 kB)+epubjs 链(345 kB)+TextReader(79 kB) 均在首装/首启 precache 清单,未打开对应阅读器的用户也要下载这 ~2.05 MB | sw.js precache 15 entries(11 JS/CSS + html + icon.svg×2 + webmanifest,2,549.92 KiB);globPatterns `**/*.{js,css,html,svg,woff2}` 未排除 | 首装 precache 仅保留 index/css/主包,reader chunk 改 runtime caching 或自定义 precache 过滤 | 后续 spec |
| 2 | epubjs 依赖链以单体共享 chunk `src-BNcXNGhn.js`(345.4 kB) 存在,体积大且被 precache 全量缓存(见 #1):虽然拆分本身是正确的——实测该 chunk 仅被 `EpubReader-*.js` 动态 import 引用,index 主包无静态引用、无 modulepreload,首屏不加载——但打开 epub 书需一次性下载 345.4 kB(gzip 103.6 kB),且单 chunk 无法按 epubjs/jszip 细分 | `src-BNcXNGhn.js` 345.4 kB:epubjs 229.0 + jszip 205.6 + @xmldom/xmldom 97.9 + localforage 65.2 kB(依赖级,源码级口径);`grep import` 确认仅 EpubReader 动态引用 | 与 #1 的 offline 策略一并处理:reader chunk 改 runtime caching 后,此 chunk 仅在用户真正打开 epub 时下载;或评估 `advancedChunks` 按 epubjs/jszip 拆小 chunk 提升缓存粒度 | 后续 spec |
| 3 | react-dom 459.8 kB(gzip 87.1 kB)是 index 主包内最大的单一依赖(依赖级,源码级口径),无 vendor 拆分;brotli 后 71.4 kB 仍是首屏最大单块 | index chunk rendered 明细:react-dom 459.8 kB / react-router 94.9 kB / query-core 64.2 kB | 评估 `build.rollupOptions.output.manualChunks`(或 vite advancedChunks)拆 vendor,提升缓存命中(业务代码改动不会使 react-dom 失效) | 后续 spec |
| 4 | `lucide-react` 34 个按需 import 的图标最终以完整组件体形式进 index(17.3 kB stat / gzip 10.1 kB——压缩比 ~59%、节省 ~41%,低于典型 JS 的 ~70% 节省水平,可能与 treeshake 不足有关,需进一步确认);且每个 reader chunk 各自带少量 lucide 模块(CbzReader 1.0 kB、PdfReader 0.7 kB、useProgress 2.8 kB)导致重复 | index 内 lucide-react rendered=17.3 kB(gzip 10.1 kB) mods=34;各 reader chunk 再零散重复 | 核查 treeshake 行为(rolldown 下 `lucide-react` named import 是否保留死代码);必要时统一走本仓库 `components/icons.tsx` 自绘 SVG | 后续 spec |
| 5 | radix-ui 单包多模块:`radix-ui` 包内 Select/Menu/Dialog/DropdownMenu/AlertDialog 等 ~29 个子模块共 ~131 kB(其中 react-select 43.0 kB、react-menu 22.1 kB;另有 radix 的定位依赖 floating-ui 46.5 kB,独立包)进 index 主包 | index chunk 内 @radix-ui/* 合计 rendered ≈ 131 kB(gzip ≈ 30 kB,依赖级口径) | 非首屏必需的 Select/DropdownMenu 所在页面(如 Shelf 筛选、admin 页)可评估下沉到对应 route chunk;仅在使用处再 import | 后续 spec |
| 6 | marked+dompurify 只被 TextReader(md 格式) 用,却进了 txt 也复用的 TextReader chunk——纯 txt 用户也下载这两个库(依赖级合计 ~113 kB,源码级口径) | `TextReader-CmvYmFNB.js` 79.3 kB 内 marked 53.7 + dompurify 59.7 kB(依赖级,源码级口径) | md 渲染再拆一层:TextReader 内对 md 分支用 `await import("marked")`/`await import("dompurify")`,txt 分支零依赖 | 后续 spec |
## 结论 / Verdict
当前总体体积(首屏 gzip 约 145 kB + CSS 12 kB)对个人书库场景可以接受,五阅读器 lazy 拆分也确实生效(epubjs 链仅被 EpubReader 动态 import,首屏不加载),pdf.worker 已是独立文件不进主包。最优先的两项是 #1(precache 全量缓存 2.05 MB reader 代码,与 spec 的离线策略直接冲突)和 #2(epubjs 链 345 kB 单体 chunk 被 precache 全量缓存,与 #1 同根),建议在下一个 spec 批次(S5 之后的优化轮)落地;#3–#6 属于收尾打磨,可与 #1 同期或更晚处理。
Total size is acceptable for a personal library (first-load gzip ≈145 kB + 12 kB CSS), and all five readers are properly split via `lazy()` (the epubjs chain is only dynamically imported by EpubReader and never loads on first paint), with pdf.worker already a standalone file. The two top priorities are #1 (precache ships ~2.05 MB of reader code users may never open — conflicts with the offline spec) and #2 (the 345 kB monolithic epubjs-chain chunk is fully precached, same root cause as #1); both belong to the next optimization round after S5. Items #3–#6 are polish that can land together with #1 or later.
---
### 实测数据来源 / Measurement provenance
- `npm run analyze`(容器内 vite build + rollup-plugin-visualizer 7.1.1,gzip/brotli 选项开启)→ `frontend/dist-stats/stats.html`;chunk/依赖级 stat、gzip、brotli 数值用 node 脚本从 stats.html 内嵌 `nodeParts`/`nodeMetas` JSON 提取(`nodeParts[uid] = {renderedLength, gzipLength, brotliLength}`,`nodeMetas[uid].moduleParts` 给出模块归属 chunk)。
- 产物文件级数值:`ls -la frontend/dist/assets/`(与 stats.html 的 chunk 级 stat/minified 大小一致;依赖级 `renderedLength` 是另一套源码级口径,见上)。
- gzip 交叉核对:宿主机 `gzip -c <file> | wc -c`(index 143,763 / PdfReader 128,873 / src-BNcXNGhn 102,696 / TextReader 26,677 / pdf.worker 366,988 / CbzReader 5,061 / EpubReader 1,500 / useProgress 2,778 / index.css 12,113 字节——与 visualizer 值存在 ≤2% 小数位差异,见现状节总注)。
- precache:`grep url:"..." frontend/dist/sw.js` 列出 15 条目(11 个 JS/CSS + index.html + icon.svg×2 + manifest.webmanifest)+ 容器内 build 输出 "precache 15 entries (2549.92 KiB)"。
- dist 总计:`du -sh frontend/dist/` = 2.6M;assets 目录 `du -b` 求和 2,609,869 字节。
- `Reader.tsx` lazy() 现状与 `dist/assets` 中四 reader chunk + pdf.worker 独立文件直接核对。
Provenance: values come from (a) the JSON embedded in `dist-stats/stats.html` produced by `npm run analyze` (rollup-plugin-visualizer 7.1.1 with gzip+brotli), extracted with a node script over `nodeParts`/`nodeMetas`; (b) `ls -la frontend/dist/assets/`; (c) host-side `gzip -c | wc -c` cross-checks; (d) `sw.js` precache URL list and the build log line "precache 15 entries (2549.92 KiB)"; (e) `du -sh frontend/dist` (2.6M) and per-file `du -b` sum (2,609,869 B); (f) direct comparison of `Reader.tsx` `lazy()` sources with the four reader chunks and the standalone pdf.worker file in `dist/assets`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,160 @@
# 后端健壮性(迁移系统 + bug 修复 + 结构重构/接口化)设计 / Backend hardening design
日期 2026-09-14。分支 `fix/backend-hardening`。状态:已获用户批准(会话内确认)。
本 spec 是项目优化四个子项目中的 **①**(其余:② 阅读器改版、③ 功能增强、④ 前端工程质量,各自独立 spec)。③ 依赖本子项目先行(功能变更需要迁移机制)。
## 目标 / Goal
1. schema 变更从「静默不生效」变为安全的有序迁移(多副本并发安全)。
2. 修复探索阶段确认的 17 个缺陷(B1–B17),每项先有复现测试。
3. 业务逻辑按 AGENTS.md 要求从 `cmd/webui/handlers` 下沉到 `internal/`,并以消费方小接口 + 手写 fake 实现可脱库单测。
4. 引入 CI workflow 文件(GitHub Actions 语法,兼容 Gitea Actions),runner 就绪前本地门禁为强制。
非目标见文末「范围外」。
## 决策记录 / Decisions
- (a)迁移 + (b)bug + (c)重构全做,按 a→b→c 分批实现、分批提交。用户选定。
- 迁移机制选**自研极简版**(embedded SQL + `schema_migrations` + pg advisory lock),否决 golang-migrate/goose(单人项目、5 张表,依赖+CLI 工作流偏重)。用户选定。
- **不支持 down 迁移**:回滚靠备份,fix-forward。决策写入 README。
- 重构深度选**方案 2 = 下沉 + 全面接口化**。用户选定。约束原则(防止接口层变负资产):
- 接口按消费方需要定义成小口径(`internal/ports`),不做镜像整个 store 的胖接口;
- 构造函数仍返回具体类型,`main.go` 手写装配,不引 DI 框架;
- 测试 fake 全部手写 in-memory 实现,不引 testify/gomock。
- 否决方案 3(最小触碰):留下 5 处缓存键手工同步与 3 套路径包含校验,正是本次要修的 bug 温床。
- CI:远端为自托管 Gitea(`git.yoresee.cc`,暂无 runner)。workflow 按标准 GitHub Actions 语法写入 `.github/workflows/`——Gitea Actions 直接兼容,未来迁 GitHub 零改动;runner 就绪前每批合入前跑本地门禁。用户选定。
- API 契约零变化,例外仅为 B6/B7/B8 的错误语义修正(记 changelog)。
## S1 迁移系统 / Migration system
- `backend/internal/db/migrations/0001_baseline.sql` = 现 `schema.sql` 原样搬入;`schema.sql` 删除。后续变更只新增 `NNNN_描述.sql`(四位序号),**已应用的文件永不修改**。
- `schema_migrations(version BIGINT PRIMARY KEY, name TEXT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`,建表语句内置于 `db.Migrate`(非迁移文件)。
- `db.Migrate(ctx, pool)` 流程:
1. `pg_advisory_lock(<固定 int64 常量,定义在 db 包>)`,defer 解锁——`--scale api=N` 副本串行化;
2. `CREATE TABLE IF NOT EXISTS schema_migrations ...`;
3. **基线检测**:`schema_migrations` 为空且 `to_regclass('books')` 非空 → 直接登记 0001 已应用,不重跑(老库原地升级);
4. `go:embed migrations` 按文件名排序,逐个未应用版本在**独立事务**内 `exec 文件 + INSERT 登记`;任一失败:回滚该事务、日志报出版本号与错误、返回 error → `main` 非零退出(compose restart 兜底,fix-forward)。
- 迁移文件校验:文件名必须匹配 `^\d{4}_[a-z0-9_]+\.sql$`,embed 列表里出现不合法名直接 panic(启动即失败,早于任何 DDL)。
## S2 bug 修复清单 / Bug fixes
每项**先写复现测试(红)→ 修(绿)**。归属层接口化后能 fake 单测的单测,否则集成测试(真 PG/Redis)。批次 B 完成 B1–B13;B14/B15/B17 随批次 A(与迁移/启动路径相邻);B16 随批次 C(依赖 `internal/upload` 下沉)。依赖未就绪新包的两处例外:B9-② single-flight 随批次 C(依赖 scanner 重构),B9-① 锁续期在批次 B 于 redispkg 现体内实现;B8 在批次 B 预建 `internal/media` 包仅放保留名纯函数,批次 C 补全该包其余内容。
| # | 缺陷(现状文件:行) | 修法 |
|---|---|---|
| B1 | `redispkg.IncrWindow`:INCR 成功但 EXPIRE 失败 → key 永不过期,该 IP **永久限流**(redis.go:55-57) | Lua 脚本原子 `INCR`+`EXPIRE`(首值时设 TTL);redis 错误维持 fail-open 返回 1 |
| B2 | `redispkg.Lock`:`rand.Read` 错误被吞 → 全零 token 可被他人偷锁(redis.go:66-67) | rand 失败 → 记日志并按故障降级路径返回 `(noop, true)` |
| B3 | `Lock` 的 unlock 用调用方 ctx:取消/关停后 Eval 静默失败,锁挂满 5min TTL(redis.go:77-81) | unlock 内部改用 `context.WithoutCancel(ctx)`;Eval 失败记日志 |
| B4 | 分片 part 以 `O_TRUNC` 直写最终名:写一半崩溃 → 截断片被 `UploadStatus` 报「已收到」(uploads.go:202-206) | 写 `parts/N.tmp` + rename;`Complete` 的总长校验保留为第二道防线 |
| B5 | 删最后 admin 是 TOCTOU(两并发请求可删光 admin);`n, _ := CountAdmins` 吞 DB 错误 → 误导性 400(users.go:85-92) | 规则下沉 store:`DeleteUser` 内部同一事务做 last-admin 检查+删除,冲突返回哨兵 `store.ErrLastAdmin`;handler 映射 400,DB 错误 → 503。自我删除检查留在 handler |
| B6 | 上传 `io.Copy` 任何失败(磁盘满/断连)都报 `413 too_large`(libraries.go:149-153) | 仅 `errors.Is(err, http.MaxBytesError)` → 413;其余 → 500 |
| B7 | `/auth/me` 把所有 store 错误(含 PG 宕机)映射 401(handlers/auth.go:50-55) | 仅 no-rows → 401;其余走既有 `dbErr` |
| B8 | 库名可叫 `cache` / `.uploads`,与 `CACHE_DIR`、上传会话目录冲突(scanner 会走缓存树、SweepStale 会误删) | `POST /libraries` 拒绝保留名 → `400 reserved_name`;保留集常量定义在 `internal/media`(布局唯一事实源) |
| B9 | scan 锁 5min TTL 不续期(大库扫描时第二副本加入同一棵树);无 redis 时每次点扫描**无上限起 goroutine**(scanner.go:61 注释、libraries.go:103) | ① 续期封装进 `ScanLock` 实现:持锁期间每 TTL/2 自动续期,unlock 停止;② scanner 加**进程内 per-library single-flight**(同库并发触发合并为一次,无 redis 也生效) |
| B10 | scanner `SetBookState` 返回值丢弃 → 坏书静默保持 ready(scanner.go:160,183) | 记 error 日志(扫描继续,不中断整轮) |
| B11 | 封面写盘错误全静默、孤儿 `.tmp`、rename 成功后仍无条件 `os.Remove(tmp)`(scanner.go:224-228、content.go:60-71) | 收敛到 `internal/media.WriteAtomic`:错误全检查、全记日志,仅失败路径清 tmp |
| B12 | `uniquePath` stat-then-create 竞态:并发同名上传选中同一候选 → `O_EXCL` 失败 500(libraries.go:168-186) | create 冲突时重取候选名,有限次重试循环 |
| B13 | `store.go` 在检查 err 前读 `res.RowsAffected()`(store.go:359-365) | 调序(先 err 后 rows) |
| B14 | serve goroutine 内 `log.Fatalf` 绕过 defer/优雅关停(main.go:50) | `srv.ListenAndServe` 错误经 channel 交回 main,统一走 shutdown 路径退出 |
| B15 | `DATABASE_URL` 空/非法延迟到 pgxpool 才报晦涩错;`REDIS_URL` 空静默禁用全部防护(config.go:65-66) | `config.Load` 校验:DATABASE_URL 必填且可解析,fail-fast 带清晰消息;REDIS_URL 允许空但打日志「redis disabled: rate-limit/scan-lock/page-cache off」 |
| B16 | `sweepUploads` 同步跑在 `UploadInit` 请求路径里(uploads.go:137) | 移入 scanner ticker(每轮顺手清),请求路径不再做全盘 ReadDir |
| B17 | `scripts/smoke.sh` 仍 POST 被忽略的 `root_path`(smoke.sh:32) | 脚本对齐现契约(只发 `{name}`) |
## S3 结构重构 + 接口化 / Restructure
### 目标布局
```
backend/
├── cmd/webui/
│ ├── main.go # 装配:具体实现 → ports 注入;启动/关停(含 B14)
│ ├── api/router.go # 路由表不变 + 新增契约测试
│ └── handlers/ # 只剩 bind/validate/调端口/哨兵错误→HTTP 码
├── internal/
│ ├── ports/ # ★ 全部消费方接口 + 跨包哨兵错误重导出
│ ├── store/ # 按聚合拆:users.go libraries.go books.go progress.go
│ │ # bookmarks.go store.go(类型/ctor/InTx);pool 收私有;
│ │ # 导出 IsUniqueViolation;删死码 ListBookIDs
│ ├── media/ # ★ 缓存布局唯一事实源 + 提取/章节(详下)
│ ├── upload/ # ★ 分片会话子系统全量下沉(详下)
│ ├── bookfile/ # + OpenReaderAt(合并 3 处 open+stat+fn 重复);
│ │ # + Contains(parent,child) 统一三套路径包含校验(EvalSymlinks 语义)
│ ├── scanner/ # add/update 合一为 ingest(persist 回调);single-flight;接管 B16
│ ├── redispkg/ # 实现 PageCache/RateLimiter/ScanLock(Lua 原子化,B1-B3)
│ ├── auth/ config/ db/ seed/ # db+迁移系统;config+校验(B15);seed 走 ports
```
### ports 接口清单(方法集按现有具体实现机械映射,签名以 plan 为准)
- `UserStore`:CountUsers / CreateUser / GetUserByName / GetUserByID / ListUsers / DeleteUser(含 B5 事务化 last-admin 规则,返回 `ErrLastAdmin`)。`CountAdmins` 从公开面消失。
- `LibraryStore`:CreateLibrary / ListLibraries / GetLibrary。
- `BookStore`:InsertBook / GetBook / ListBookMeta / UpdateBookFile / DeleteBookByPath / DeleteBook / SetBookState / ListBooks / BookHashes。
- `ProgressStore`:UpsertProgress / GetProgress / ListProgress。
- `BookmarkStore`:InsertBookmark / ListBookmarks / UpdateBookmarkNote / DeleteBookmark。
- `PageCache`(消费方:media):Get(ctx,key) (string,bool) / Set(ctx,key,val,ttl)。
- `RateLimiter`(消费方:auth handler):IncrWindow(ctx,key,ttl) int。
- `ScanLock`(消费方:scanner):Lock(ctx,key,ttl) (unlock func(), ok bool),实现内部自动续期(B9)。
- `UploadSessions`(消费方:handlers/uploads):Init / Status / PutPart / Complete / Sweep;哨兵 `ErrTooLarge` `ErrIncomplete` `ErrSizeMismatch` `ErrNotFound`。
- `Media`(消费方:handlers/content、scanner):EnsureCover / EnsurePage / ChaptersOf / PageIndex / CacheBuster;哨兵 `ErrBrokenArchive`。
- `Scanner`(消费方:handlers/libraries):ScanLibraryByID。
哨兵错误定义在所属实现包,`ports` 统一重导出供 handler `errors.Is` 映射;pg 错误分类收敛为 `store.IsUniqueViolation(err)` 单一谓词(替代 handlers.dbErr/users.isUnique/seed 三份拷贝),no-rows 判断维持 `errors.Is(err, pgx.ErrNoRows)`。
### internal/media(缓存与提取的唯一事实源)
收拢目前散布在 scanner、handlers/content、handlers/books 的隐式共享知识:
- 布局与键:`DirKey(id,size,modTS)`、`CoverDir`、`PagesDir`、`CacheBuster`(`?v=` hash)、保留名集合(B8)——5 处手工同步归一。保留名校验以**纯函数** `media.IsReservedName(name) bool` 暴露,libraries handler 直接 import 使用(无 I/O,不进 Media 接口、不需 fake)。
- `WriteAtomic`:唯一 tmp+rename 实现(替代 5 处拷贝,B11)。
- 提取:cbz/epub 封面、cbz 页(含自愈:磁盘缓存缺失时按需重建,现 content.go 的懒加载逻辑迁入);`ChaptersOf`(现 handlers 的 chaptersOf 纯域规则迁入);`PageIndex`(zip 索引 + redis 缓存策略,键 `pagesidx2:*` 不变)。
### internal/upload(分片会话子系统)
现 handlers/uploads.go 全部 285 行域逻辑迁入:会话 id 派生(sha256 确定性)、目录布局(`<BooksDir>/.uploads/<uid>/{meta.json,parts/N}`)、分片校验(≤32MB、索引合法)、meta 读写(损坏 meta 记日志并按新会话处理,不再静默摧毁)、TTL 清理(Sweep,由 scanner ticker 调)、拼装+原子落盘+去重后缀(B12 的重试在此实现)。`UniquePath` 以导出函数住在 internal/upload,单发上传 handler 与分片拼装共用同一份。handler 只剩 JSON 绑定、调端口、哨兵→HTTP 码。
### handlers 去重(随下沉自然消除)
- `getLibrary`/`getLibRow` 二合一;`ParseInt(c.Param("id"))` 样板 → 单一 `idParam(c)` helper。
- 上传校验(SafeName+FormatFromExt+同一错误文案)单发/分片两路共用一份(住在 internal/upload)。
- 路径包含校验统一 `bookfile.Contains`(三套实现收敛为 EvalSymlinks 语义一套)。
### 测试
- `api/router_test.go` 扩为**路由契约测试**:测试内 pin 一份期望路由表(golden 集合),遍历 gin `Routes()` 断言与之完全一致、admin 路由挂 AdminOnly 中间件——任何未过审的路由增删改都会红。
- 新增 `handlers/*_unit_test.go`:fake(内存实现 ports)驱动,无 PG/Redis 可跑,覆盖哨兵→HTTP 码映射与 bind/validate 分支。
- fake 统一住在 `internal/ports/portsfake` 一个共享包,手写、无生成器。
- 现有 47 个集成测试全保留;测试助手去重(writeCBZ/testCfg/setup 收敛到共享测试包)。
## S4 CI 与验证 / CI & gates
- `.github/workflows/ci.yml`(Gitea Actions 兼容语法):
- job `backend`:actions/checkout + actions/setup-go(版本读 go.mod)+ service 容器 `postgres:16`、`redis:7`;步骤:`gofmt -l .`(输出非空即败)、`go vet ./...`、`go test -p 1 -count=1 ./...`(注入 `DATABASE_URL`/`REDIS_URL` 指向 service,CI 中不存在 skip 路径)。
- job `frontend`:actions/setup-node + `npm ci` + `npm run check`。
- workflow 语法本地用 `actionlint`(`go run` 一次性执行,不入 go.mod)自检。
- docs/README.md + README_zh.md(同步):重写「改 schema 前必读」为迁移工作流;新增 CI 节(如何在 Gitea 开启 Actions/注册 act_runner;迁 GitHub 零改动)。
- **本地门禁(runner 就绪前强制)**:每批合入前 dev compose 起 PG+Redis,`go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...` 确认 **0 skip**,再跑 `scripts/smoke.sh` + `scripts/smoke-web.sh`。
- 批次 A 额外验证**老库基线路径**:先用当前 master 镜像建库建表,再换本分支启动,断言 `schema_migrations` 被基线为 0001 且无 DDL 重跑。
- 分支 `fix/backend-hardening`;本 spec 与实现同分支提交。
## 实现批次 / Batches
1. **A**:迁移系统(S1)+ CI workflow(S4)+ B14/B15/B17。门禁:本地全量 + 老库基线验证 + actionlint。
2. **B**:B1–B13,每项复现测试先行。门禁:本地全量(新测试含 fake 前置形态,接口未拆前允许先以集成测试写就,批次 C 迁移为单测)。
3. **C**:S3 全部(ports/store 拆分/media/upload/scanner/bookfile/handlers 瘦身)+ B16 + 契约测试 + fake 单测。纯结构、行为不变。门禁:本地全量 + smoke + 契约测试绿。
每批独立提交(`git commit` 粒度按聚合/主题),批内保持测试常绿;CHANGELOG 条目在对应批次落地时写入。
## 文档与 changelog 义务 / Docs
- `docs/CHANGELOG.md`(非 WebUI,双语同条、条目间空行)至少记录:迁移系统(Changed)、B1 永久限流(Fixed)、B4 截断分片(Fixed)、B6 413 语义(Fixed)、B7 me 错误语义(Fixed)、B8 reserved_name(Changed);其余内部修复酌情合并一条。
- README 双版同步(S4 所列两节)。
- AGENTS.md 无需改动(本次是向它的规则收敛)。
## 范围外 / Out of scope (YAGNI)
- down 迁移、迁移 CLI 工具化。
- 列表分页、JWT 吊销/刷新、库重命名/删除、扫描状态 API、元数据编辑——子项目 ③。
- 任何前端改动——子项目 ②/④。
- DI 框架、mock 生成器、`pkg/` 公开面。
- 数据库层性能(连接池参数调优、索引审计)——无证据表明当前是瓶颈。
@@ -0,0 +1,111 @@
# 前端工程质量(lint/format + 组件测试 + e2e/a11y + bundle 分析)设计 / Frontend engineering quality design
日期 2026-09-15。分支 `docs/frontend-quality-spec`(设计);实施分支另建(建议 `feat/frontend-quality`)。状态:已获用户批准(会话内确认)。
本 spec 是项目优化四个子项目中的 **④**(其余:② 阅读器改版、③ 功能增强;① 后端健壮性已合入 master,见 `2026-09-14-backend-hardening-design.md`)。④ 与 ② 存在排序耦合:本 spec 先落地 lint/测试/e2e 基建,② 改阅读器时即有规范与回归网可用;阅读器组件测试明确留给 ②。
## 目标 / Goal
1. 引入 ESLint + Prettier,全库一次到位 0 error,进入 `npm run check` 与 CI 门禁。
2. 建立组件测试基建(testing-library + jsdom),公共组件有少量样板测试;阅读器组件不在本期。
3. 建立 Playwright e2e 基建,覆盖登录/书架/阅读/书签主流程与 admin 冒烟,含关键页 axe a11y 扫描;发布前手动门禁。
4. 接入 bundle 分析工具并产出书面审视结论;不做实际优化。
非目标见文末「范围外」。
## 决策记录 / Decisions
- 五个方向(lint/format、组件测试、e2e、a11y、bundle 分析)**全部纳入**本期,单 spec、四批次依序落地(方案 A)。用户选定。否决:B(全部基建一次接通再集中修,中间态长、难定位)、C(拆两份 spec 后置 e2e/a11y/bundle,会削弱 ② 的回归安全网)。
- lint 工具选 **ESLint 9 flat config + Prettier**(独立格式化),否决 Biome(a11y 规则覆盖弱于 jsx-a11y)与 Biome+ESLint 混合(双工具维护成本)。用户选定。
- lint 严格度:**全库 0 error 一次到位**,不做 baseline 渐进。过噪规则显式降 warn 或关闭,且必须在 config 内注释理由。用户选定。
- 组件测试深度:**基建 + 少量样板**(2-3 个文件,验证基建可用),阅读器组件测试留给 ② 随新 chrome 一起写。用户选定。
- e2e 运行方式:**针对 dev compose 栈**(默认 `http://localhost:5173`),`npm run e2e` 为发布前手动门禁,不进 `npm run check`;CI 加独立 job,`workflow_dispatch` 触发。用户选定。否决:进 check(日常摩擦大)、mock 后端(测不到集成层、与真实后端漂移)。
- a11y 分层:**静态 jsx-a11y(批次 1 随 eslint 接通)+ e2e 关键页 axe 扫描(批次 3)**;不做组件测试层 axe 断言(与 e2e 扫描重叠)。用户选定。
- bundle 分析:**只接工具 + 出书面结论**(`docs/bundle-review.md`),优化动作不在本期。用户选定。
- 所有新依赖在 dev 容器内安装并提交 lockfile(AGENTS.md 约束);Playwright 浏览器二进制仅 dev 容器手动安装(文档化),不进 prod 镜像。
## S0 批次总则 / Batches
四批次依序实施、依序提交,每批独立可回滚:
- B1 lint/format → B2 组件测试基建 → B3 e2e + axe → B4 bundle 分析。
- 每批验收底线:`npm run check` 保持绿 + 该批新增命令可跑通。
- B1 最先,使后续批次新写的测试/e2e 代码天然符合 lint/format 规范。
- changelog:纯 `frontend/` 改动记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新版本在上);`docs/bundle-review.md` 与 README 改动随 B4 同批,不重复记入 `docs/CHANGELOG.md`。
## S1 批次 1:ESLint + Prettier / Lint & format
- 依赖(dev):`eslint`、`typescript-eslint`、`eslint-plugin-react-hooks`、`eslint-plugin-jsx-a11y`、`eslint-config-prettier`、`prettier`(`prettier-plugin-tailwindcss` 可选,接入与否在实施时以噪音程度定,接则 class 自动排序)。
- 配置:`frontend/eslint.config.js`(flat config),基线 = `typescript-eslint` recommended + react-hooks recommended + jsx-a11y recommended + `eslint-config-prettier` 收尾关闭格式冲突规则。按目录 override:`e2e/`(B3 产物)用 node 环境 globals、放宽 e2e 惯例规则;`test/`、`*.test.tsx` 允许测试专用 globals。
- Prettier:`frontend/.prettierrc`(对齐现有代码风格:2 空格、单引号——以实施时对存量代码 diff 最小化为准)+ `.prettierignore`(`dist/`、`node_modules/`、`package-lock.json`、`dist-stats/`)。
- scripts(`frontend/package.json`):`lint`(eslint .)、`lint:fix`、`format`(prettier --write .)、`format:check`;`check` 改为 `tsc --noEmit && eslint . && prettier --check . && vitest run && vite build`。
- 存量修复:全库 eslint error 清零;prettier 首次全量 format 单独成一个 commit(纯格式、无逻辑改动),便于 review 与回滚。
- 降 warn/关闭的规则须在 `eslint.config.js` 内逐条注释理由(例如 jsx-a11y `click-events-have-key-events` 对阅读器中央点击区可先 warn,② 改版时收严)。
- CI:frontend job 已跑 `npm run check`,lint 自动覆盖,无需改 workflow。
验收:`npm run check` 绿;`eslint .` 0 error;`prettier --check .` 0 diff。
## S2 批次 2:组件测试基建 / Component testing
- 依赖(dev):`jsdom`、`@testing-library/react`、`@testing-library/user-event`、`@testing-library/jest-dom`。
- 环境分层:存量 `frontend/test/*.test.ts` 纯逻辑测试保持 node 环境不动;组件测试用 `*.test.tsx` 后缀,vitest `environmentMatchGlobs`(或等价的项目级配置)将 `**/*.test.tsx` 路由到 jsdom;`vitest run` 一次全跑,不加新 script。
- setup:`frontend/test/setup.ts` —— 注册 jest-dom matchers、自动 cleanup、补 jsdom 缺口 polyfill(`ResizeObserver`、`Element.scrollIntoView`、pointer events),radix 组件在 jsdom 下的已知坑一次配好。
- 放置约定:组件测试与组件同目录 colocate(如 `src/components/ui/button.test.tsx`);纯逻辑测试维持 `frontend/test/`。约定写入 B1 的 eslint override 与本 spec 附录(S6)。
- 样板测试(3 个,目的是验证基建而非追覆盖率):
1. `src/components/ui/button.test.tsx`:渲染、variant class、点击回调;
2. `src/components/ui/dialog.test.tsx`:radix Dialog 在 jsdom 下开合、焦点管理(验证 polyfill 方案成立——② 的 chrome 迁移依赖 Sheet/Popover 等同类原语);
3. `src/components/theme-toggle.test.tsx`:真实业务组件,验证 localStorage/主题上下文可测。
- 阅读器组件(`src/readers/{Cbz,Epub,Pdf,Text}Reader.tsx`)本期不写组件测试;spec 显式约定 ② 改版时随新 chrome 补齐并作为行为回归网。
验收:`npm run check` 绿;3 个样板测试通过;存量 9 个逻辑测试不受影响。
## S3 批次 3:e2e + a11y 运行时扫描 / e2e & axe
- 依赖(dev):`@playwright/test`、`@axe-core/playwright`;浏览器仅装 chromium(dev 容器内 `npx playwright install --with-deps chromium`,命令写入 README 与本 spec)。
- 结构:`frontend/e2e/*.spec.ts` + `frontend/playwright.config.ts`;`baseURL` 取环境变量 `E2E_BASE_URL`,默认 `http://localhost:5173`(dev compose 栈 web 端口)。**不由 Playwright 管理 server 生命周期**——前提是手动起好 dev 栈(`deploy/docker-compose.dev.yml`),连不上直接失败并给出提示。
- 测试数据:全部走 HTTP API 准备(注册/登录、上传 `e2e/fixtures/` 下的小 CBZ 文件),不直接碰 PG/Redis;登录态用 Playwright `storageState` 复用,避免每条用例重复登录。
- 用例(2 个 spec,不追全量):
1. `e2e/auth-shelf.spec.ts`:登录 → 书架可见 → 进入 CBZ 书 → 翻页 → 加入书签 → 退出登录;
2. `e2e/admin-smoke.spec.ts`:admin 登录 → 建库 → 触发扫描 → 扫描入口/状态可见(冒烟级;扫描状态 API 属 ③,此处只验证现有行为)。
- axe 扫描:在关键页面(登录页、书架页、阅读器 chrome 展开态)插入 `@axe-core/playwright` 扫描步骤。门禁分级:
- critical/serious 违规 = 测试红;moderate/minor 仅输出报告不阻断;
- 存量 critical/serious 违规本批内修掉(多为属性级小修,记 `CHANGELOG_web.md`);确属将被 ② 重写的旧阅读器 chrome 的问题,允许在测试内逐条显式 allowlist 豁免,每条注释指向 ②。
- scripts:`e2e`(playwright test)、`e2e:ui`(playwright test --ui,开发调试用)。**不进 `npm run check`**。
- CI:`ci.yml` 新增独立 `e2e` job,仅 `workflow_dispatch` 触发,job 内自起 PG/Redis/api/web(service 容器 + build),runner 未就绪期间形同文档,就绪后零改动可用。
- eslint:`e2e/` 目录 override(node globals、测试文件规则)。
验收:dev 栈起后 `npm run e2e` 绿(2 spec + 关键页 axe 无未豁免的 critical/serious)。
## S4 批次 4:bundle 分析 / Bundle analysis
- 依赖(dev):`rollup-plugin-visualizer`;接入方式为 `vite.config.ts` 内按条件启用(如 `ANALYZE=1` 或 `--mode analyze`),产物输出到 `frontend/dist-stats/`(加入 `.gitignore` 与 `.prettierignore`,不进 dist 发布产物)。
- script:`analyze`。
- 审视结论文档:`docs/bundle-review.md`(英中双语,随批 commit)。内容:各大依赖(pdfjs-dist、epubjs、marked、dompurify、react-query、radix-ui、lucide-react 等)的体积占比、当前是否已懒加载/分包、可优化点清单;每个优化项标注建议归属(② 或后续 spec),**本期不动代码**。
- README:`docs/README.md` 与 `docs/README_zh.md` 同批补充新增命令(lint/format/analyze/e2e/e2e:ui)与本地门禁清单更新,保持双语内容等价(AGENTS.md 约束)。
验收:`npm run analyze` 可出报告;`docs/bundle-review.md` 提交;README 双语等价。
## S5 整体验收 / Definition of Done
1. `npm run check` 绿 = tsc + eslint 全库 0 error + prettier check + vitest(9 存量逻辑测试 + 3 新组件样板)+ build;
2. dev 栈起后 `npm run e2e` 绿;
3. `npm run analyze` 可出报告,`docs/bundle-review.md` 已提交;
4. `docs/CHANGELOG_web.md` 双语条目齐全;`docs/README.md` / `README_zh.md` 等价更新;
5. CI:frontend job 无需改动即覆盖 lint;新增 e2e job(workflow_dispatch)就绪;
6. lockfile 已提交;prod 镜像不新增任何本期依赖。
## S6 范围外 / Out of scope
- 阅读器组件的任何测试与改动(→ ② 阅读器改版);
- bundle 体积的实际优化动作(仅出结论清单);
- moderate/minor 级 a11y 修复(仅输出报告);组件测试层 axe 断言;
- Playwright 浏览器进 prod 镜像;e2e 进 `npm run check`;
- 后端任何改动(含扫描状态 API → ③);
- `components/ui.ts`(deprecated)与 `components/icons.tsx` 的清理(→ ②,本期 lint 仅按现状规则放行或豁免,不做删除)。
## 附录:测试放置与命名约定 / Appendix: test conventions
- 纯逻辑测试:`frontend/test/<module>.test.ts`(现状不变,node 环境);
- 组件测试:与被测组件同目录 `<name>.test.tsx`(jsdom 环境);
- e2e:`frontend/e2e/<flow>.spec.ts`,fixture 放 `frontend/e2e/fixtures/`;
- 测试命名:`describe` 用被测单元名,`it` 用行为描述句。
@@ -0,0 +1,132 @@
# 阅读器改版(chrome 迁移 + 阅读新特性)设计 / Reader revamp design
日期 2026-09-16。分支 `docs/reader-revamp-spec`(设计);实施分支另建(建议 `feat/reader-revamp`)。状态:已获用户批准(会话内分节确认)。
本 spec 是项目优化四个子项目中的 **②**(① 后端健壮性、④ 前端工程质量已合入 master;③ 功能增强独立 spec)。边界来源:`2026-09-08-modern-redesign-shadcn-design.md` 的「Phase 2」节;基建依赖 ④ 已交付(eslint/组件测试/e2e/axe 回归网)。
## 目标 / Goal
1. 四阅读器(CBZ/Text/EPUB/PDF)chrome(头栏/工具条/目录/书签面板/主题卡)迁入 shadcn token + radix 原语,删除全部 `rd-*` 组件类与裸色类,删除 deprecated 的 `components/ui.ts` 与 `components/icons.tsx`。
2. 「纸/米/夜」阅读主题重映射为语义 token,并实现与全局 light/dark 的「默认联动 + 手动覆盖」规则。
3. 五个新特性:CBZ 横向翻页模式(长卷/单页/双页 + RTL)、txt/md 书内搜索、统一阅读设置面板(底部抽屉扩展)、阅读统计(localStorage + 可替换接口,书架顶部统计卡)、EPUB 排版设置(epubjs themes)。
4. 回收 ④ 留下的收严清单:eslint reader 豁免 override(4 条 jsx-a11y warn)删除回 error、axe `WAIVERS`(reader-chrome color-contrast)清空重扫、TxtView/CbzReader 的行级 disable 重估。
5. 顺带修复 ④ 终审遗留的 tsc 盲区(`e2e/`、`*.config.ts` 纳入类型检查)。
非目标见文末「范围外」。
## 决策记录 / Decisions
- 范围 = **chrome 迁移 + 新特性**(用户选定,否决「纯迁移」与「迁移为主+逐个勾选」)。五特性由用户从候选清单多选圈定;快捷键速查面板落 backlog。
- 实施策略 = **基建先行 + 按阅读器纵切**(方案 1,用户选定):B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。否决:两段式(迁移批过大、特性二次触碰)、特性先行(旧壳上写特性必返工)。
- 主题联动 = **默认联动 + 手动覆盖**(用户选定):`themeMode:"auto"` 时全局 dark→夜、light→纸;手动选卡记住手动值;设置面板提供「跟随全局」重置。否决:完全独立、完全同步。
- 统一设置面板形态 = **A 底部抽屉扩展**(用户经视觉伴侣选定):现有沉浸底部 sheet 长大,常驻行 + 按格式条件渲染的「更多设置」展开区。否决:右侧 Sheet、工具条 Popover。
- 书内搜索交互 = **导航抽屉第三 tab**(用户选定),否决顶部 overlay 搜索栏。
- 阅读统计 = **localStorage 记录 + `StatsStore` 可替换接口**(用户选定,③ 未来可换服务端实现);展示 = **书架顶部可折叠统计卡**(用户选定),书卡不加元素。
- CBZ 翻页 = **长卷/单页/双页 + RTL 独立开关**(用户选定);双页为手动配对,**不做自动跨页检测**;RTL 仅对页模式生效。
- EPUB 排版 = **字号/行距/边距 + 主题映射**(用户选定),不引入自定义字体族/字体文件。
- tsc 盲区修复**纳入 ②**(用户选定)作为 B0 的一个任务。
- **无 localStorage 兼容要求**(用户明确裁定):偏好统一收进单一 `reader-prefs` 新结构,旧独立键 `cbz-continuous`/`cbz-prefetch` 直接废弃(不读不写不迁移),老用户偏好重置为新默认值。服务端进度/书签 locator 格式本期不变(page/spread 与 `{page}` 天然兼容)。
- 行为保留约束(继承 Phase 2 边界):翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报的**行为逻辑**原样保留(只换壳 + 明确圈定的新特性);④ 期间的行为等价修复(渲染期重置等)不回退。
## S0 批次总则 / Batches
六批依序实施、依序提交:B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。
- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(既有 spec 不回归)+ 该批新增测试通过。
- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新在上);每批随批记。
- 新依赖(如有)容器内安装并提交 lockfile;本期预期**零新运行时依赖**(radix/shadcn/lucide 已在),仅可能新增 `@types/node`(dev)。
## S1 B0 横切基建 / Cross-cutting foundation
**阅读主题 token 与联动**
- 保留 `data-rd={paper|sepia|night}` 驱动机制;`--rd-bg/--rd-fg/--rd-link` 扩展为完整 token 组(补 `--rd-muted/--rd-accent/--rd-border`),供迁移后 chrome 使用。阅读面用 `--rd-*` token,抽屉/弹层容器用 shadcn 语义 token。
- `themeMode: "auto" | "paper" | "sepia" | "night"`,默认 `auto`(全局 dark→night、light→paper,实时跟随);手动选主题卡即切换为手动值;设置面板「跟随全局」按钮重置回 auto。
**readerPrefs 统一结构(无兼容负担)**
- 单一 localStorage 键 `reader-prefs`,新结构:`{ themeMode, text:{sizeIdx,lineIdx,marginIdx}, cbz:{mode:"strip"|"page"|"spread", rtl:boolean, continuous:boolean, prefetch:0|1|2|3}, epub:{sizeIdx,lineIdx,marginIdx} }`;宽容解析(缺字段/坏值回默认)。旧 `cbz-continuous`/`cbz-prefetch` 键废弃。
- `useReaderPrefs` 重构为该结构的读写 hook(现有消费点随各阅读器批次迁移)。
**统一设置抽屉(形态 A)**
- 新增 shadcn 原语文件 `ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`(基于已装的 `radix-ui` 包,零新 npm 依赖,模式对齐现有 `ui/*.tsx`)。
- `components/reader-settings.tsx`:底部 sheet(radix Sheet 原语 + shadcn token),常驻行 = 进度滑条(shadcn Slider)+ 主题卡(含「跟随」态)+ A−/A+;「更多设置」展开区按格式条件渲染:CBZ = 翻页模式/方向/连读/预读;Text = 行距/边距;EPUB = 字号/行距/边距;PDF = 无。
- 替代旧 `rd-sheet/rd-btn/rd-range` 与 `rd-slider.tsx`(组件在 B5 删除)。
**统计记录层**
- `lib/readingStats.ts`:`StatsStore` 接口(`record(seconds)` / `weekly()` / `streak()`)+ localStorage 实现(键 `reading-stats`,按日桶 `{ "YYYY-MM-DD": seconds }`)。
- 记录时机:Reader 页可见期间 30s 心跳 + 卸载 flush(`visibilitychange`/`pagehide`)。纯函数(日桶聚合/周合计/连续天数)独立导出供单测。
**tsc 盲区修复**
- `frontend/tsconfig.json` include 扩为 `["src", "test", "e2e", "*.config.ts"]` + dev 依赖 `@types/node`;存量 e2e/config 代码先过一遍 `tsc --noEmit` 清零。
## S2 B1 CBZ(迁移 + 翻页模式 + RTL)
**chrome 迁移**
- `pages/Reader.tsx` 头栏:`stone-950`/`btn`/`btnGhost`/`formatBadge` → shadcn token + `ui/button`(ghost)+ `ui/badge`;`IconArrowLeft` → lucide `ArrowLeft`。
- `components/reader-nav.tsx` → radix Sheet(side="left")+ shadcn Tabs 样式;`Bookmarks.tsx` 内容结构不变、`rd-btn` → `ui/button`。
- 底部工具条 → S1 的 `reader-settings`。
**翻页模式 + RTL**
- `strip`(长卷)= 现有渲染路径原样保留:章锁/连读/预读/滚动定位/章末卡片零改动。
- `page`/`spread` 新渲染路径:按页索引显示当前页;spread 手动配对 `[2i, 2i+1]`;翻页 = 索引步进,复用 `PageImg` 解码缓存预载相邻页;章锁在页模式 = 章末页后显示「本章完」卡片页,点击进下一章(锁章止步);`continuous` 在页模式 = 越过章末卡片自动进下一章;预读三模式通用。
- RTL 独立开关:右缘点击 = 上一页、键盘 ←/→ 映射翻转、spread 右页为先;仅 page/spread 生效。
- 点击区约定沿用:两侧翻页、中央收放 chrome。模式/方向切换保持当前页;书签 `{page}` locator 与进度上报(页索引→percent)兼容不变。
- 双页配对与 RTL 方向映射抽纯函数进 `lib/`(单测)。
**测试**:配对/方向纯函数单测;模式切换组件测试;e2e 扩展单页模式翻页 + RTL 冒烟。CbzReader 迁移后其 eslint warn 豁免与 axe WAIVERS 应自然消除(B5 验证)。
## S3 B2 Text(迁移 + 搜索 + 排版)
**chrome 迁移**:阅读面保留 `rd-surface`/`data-rd` token 机制(用 S1 扩展后变量);顶栏/章节选择器(含卷分组)/底部栏迁 shadcn + radix;主题卡/A−A+ 收进 `reader-settings`;书签抽屉复用 B1 的 `reader-nav`。分章/分卷/编码回退/进度恢复逻辑原样保留;TxtView 的 set-state disable(④ 遗留)迁移时重估:能以渲染期重置修复则修,否则保留注释并登记 B5 清单。
**书内搜索(导航抽屉第三 tab,仅 Text)**
- 输入 250ms 防抖;对内存章节文本线性扫描(大小写不敏感),全局结果上限 500;结果行 = 章节标题 + snippet(命中 ±~20 字符,`<mark>` 高亮)。
- 点击结果 → 复用章节跳转定位到章、滚动至命中处短暂高亮;不产生书签;locator 格式不变。
- 扫描/snippet/高亮切分抽纯函数进 `lib/search.ts`(单测)。
**排版设置**:行距五档(1.2/1.4/1.6/1.8/2.0)+ 边距三档(窄/中/宽 → max-width),进 `reader-settings`,持久化 `reader-prefs.text`。
**测试**:`lib/search.ts` 单测;搜索 tab 组件测试;新增 `e2e/fixtures/e2e-sample.txt` + e2e 搜索冒烟(输入→结果→跳转断言)。
## S4 B3 EPUB(迁移 + 排版设置)
- chrome 迁移:顶栏/「更多阅读选项」dropdown → shadcn + radix DropdownMenu/Sheet;书签抽屉复用 `reader-nav`;CFI 进度/书签原样保留。
- 排版:epubjs `rendition.themes` API——字号(复用 `FONT_SIZES`)/行距/边距 register 为 overrides;「纸/米/夜」映射 EPUB 背景/文字色,按 `themeMode`(含 auto 联动)`themes.select`;持久化 `reader-prefs.epub`。
- **测试限制(如实记录)**:epubjs 渲染在 iframe 内,组件测试不可行;headless 稳定性差,本期不加 EPUB e2e/fixture。验收走手工清单:开书 → 改字号/行距/边距/主题各一次 → 视觉生效 → 进度恢复。
## S5 B4 PDF(迁移)+ 书架统计卡
- PDF:pdfjs 渲染/页码定位/书签/进度原样保留;顶栏/工具条 chrome → shadcn + radix;pdf.worker 配置不动;无新增排版设置。
- 统计卡:`Shelf.tsx` 筛选栏上方可折叠卡(折叠态记忆 localStorage)——「本周阅读 X 小时 Y 分 · 连续 N 天」,展开显示近 7 日按日柱状图(纯 div 高度百分比,不引图表库);数据来自 `StatsStore.weekly()/streak()`;书卡不加元素。
- 测试:统计纯函数单测(B0 已列);统计卡组件测试用 fake `StatsStore` 注入;e2e 仅断言卡片存在(不断言时间敏感数值)。
## S6 B5 清理与收严 / Cleanup & tightening
- 删除 `components/ui.ts`(使用点先迁 `ui/button`/`ui/badge`)与 `components/icons.tsx`(→ lucide)。
- `index.css` 删除全部 `rd-*` 组件类(rd-btn/rd-sheet/rd-range/rd-row/rd-highlight/rd-divider 等)与阅读器裸色类(stone-* 等);`--rd-*` token 变量与 `data-rd` 机制保留。验收:`grep -rn 'rd-' src/ --include='*.tsx'` 零命中(`--rd-*`/`data-rd` 除外)。
- 收严清单回收:① `eslint.config.js` 删除 reader 豁免 override(4 条 jsx-a11y warn 回 error),迁移后代码必须真达标;② `e2e/helpers/axe.ts` 的 `WAIVERS` 清空,reader 相关页重扫零 critical/serious;③ TxtView/CbzReader 行级 disable 重估(修复或留带理由注释,AuthContext 的 disable 与阅读器无关、按 ④ 结论保留)。
- 测试收口:设置抽屉组件测试(改字号→断言正文 style);e2e 补「设置面板冒烟」。
- changelog:每批用户可见条目已在各批记;B5 补 Changed 条目(旧样式类删除/主题联动)。
## S7 验收 / Definition of Done
1. `npm run check` 绿(含 tsc 新覆盖 e2e/config);`npm run e2e` 绿(扩展后全部 spec)。
2. 手工走查清单:四阅读器 × 三阅读主题(+auto 联动)× 375/768/1280 三宽 × light/dark;翻页/书签/进度恢复/沉浸收放行为与迁移前一致(新特性除外)。
3. `ui.ts`/`icons.tsx` 已删;rd-* 组件类 grep 零命中;eslint reader override 与 axe WAIVERS 已移除。
4. changelog(双语)与 lockfile 齐;prod 镜像无新依赖。
## S8 范围外 / Out of scope
- EPUB 书内搜索;CBZ 自动跨页检测;快捷键速查面板(backlog);
- 阅读统计服务端化(③ 候选,本期仅留 `StatsStore` 接口缝);
- PDF 排版设置;自定义字体族/字体文件;i18n;
- 后端任何改动(API/迁移/契约零变化);
- 书卡元素变更;书架布局改版;
- localStorage 旧偏好兼容(用户裁定不需要)。
## 附录:backlog(本期未选,记录备查)
- 快捷键速查面板(阅读页 `?` 弹出);
- CBZ 自动跨页检测(宽>高单显、相邻竖图配对);
- EPUB 书内搜索(epubjs search API);
- 阅读统计服务端化 + 跨设备(③ 联动);
- ④ 遗留:`--legacy-peer-deps` 已落 `.npmrc`(闭合);vitest `src/**/*.test.ts`(非 tsx)静默缝隙——B0 顺手在 vitest.config 注释或 include 收紧。
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+6
View File
@@ -0,0 +1,6 @@
dist
dist-stats
node_modules
package-lock.json
playwright-report
test-results
+7
View File
@@ -0,0 +1,7 @@
{
"printWidth": 120,
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+7 -1
View File
@@ -5,5 +5,11 @@
"tsx": true,
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
"iconLibrary": "lucide",
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from "@playwright/test";
import { creds } from "./helpers/api";
test("admin:建库 → 触发扫描(冒烟)", async ({ page }) => {
const { user, pass } = creds();
const libName = `e2e-admin-${Date.now()}`;
await page.goto("/login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
await page.goto("/admin/libraries");
await page.getByPlaceholder(/库名/).fill(libName);
await page.getByRole("button", { name: "建库" }).click();
await expect(page.getByText(`库「${libName}」已创建`)).toBeVisible();
// name cell 加 exact:路径 cell(服务端目录 /data/books/<库名>)的名称也含库名,子串匹配会 strict 双命中。
await expect(page.getByRole("cell", { name: libName, exact: true })).toBeVisible();
const row = page.getByRole("row", { name: new RegExp(libName) });
await row.getByRole("button", { name: /扫描/ }).click();
await expect(page.getByText("扫描已触发")).toBeVisible();
});
+89
View File
@@ -0,0 +1,89 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { adminApiRetry, creds, ensureSampleBook } from "./helpers/api";
import { scanA11y } from "./helpers/axe";
let admin: APIRequestContext;
let bookId: number;
let title: string;
test.beforeAll(async () => {
admin = await adminApiRetry();
bookId = await ensureSampleBook(admin, "e2e");
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
});
test.afterAll(async () => {
await admin?.dispose();
});
test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ page }) => {
const { user, pass } = creds();
// 登录页 + axe
await page.goto("/login");
await scanA11y(page, "login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
// 书架 + axe;样本卡可见并点进阅读器。
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
const card = page.locator(".group > button", { hasText: title });
await expect(card).toBeVisible();
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
await scanA11y(page, "shelf");
await card.click();
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
// 阅读器(chrome 默认展开)+ axe
const reader = page.getByLabel("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
await expect(reader).toBeVisible();
await expect(page.getByText("1/3")).toBeVisible();
await scanA11y(page, "reader-chrome");
// 右缘点击翻屏 → 页码前进。
// 单次 scrollBy 一屏未必把视口中心推过第 1 页底(页高 > 一屏),故按实况重试点击直到 2/3。
const box = await reader.boundingBox();
if (!box) throw new Error("阅读器容器无 boundingBox");
await expect(async () => {
await page.mouse.click(box.x + box.width * 0.9, box.y + box.height / 2);
await expect(page.getByText("2/3")).toBeVisible();
}).toPass({ timeout: 20_000 });
// 页模式 + RTL 冒烟:开设置面板 → 单页 → 右缘翻页 → 右开本(RTL) 方向翻转 → 回长卷。
await page.getByRole("button", { name: "更多阅读设置" }).click();
await page.getByRole("button", { name: "单页" }).click();
await expect(page.getByText(/^1\/3 · /)).toBeVisible(); // 模式切换保持当前页:此时在第 2 页
// 回到第 1 页:左缘点击
const box2 = await reader.boundingBox();
if (!box2) throw new Error("阅读器容器无 boundingBox");
await page.mouse.click(box2.x + box2.width * 0.1, box2.y + box2.height / 2);
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
// 右缘点击 → 第 2 页
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
await expect(page.getByText(/^2\/3 · /)).toBeVisible();
// RTL:右缘点击变后退
await page.getByRole("button", { name: "右开本(RTL)" }).click();
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
// 回长卷
await page.getByRole("button", { name: "长卷", exact: true }).click();
await expect(page.getByText("1/3")).toBeVisible();
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
await page.getByRole("button", { name: "导航", exact: true }).click();
const nav = page.locator("#reader-nav");
await expect(nav).toBeVisible();
await nav.getByRole("button", { name: "加书签" }).click();
await expect(page.getByText("已加书签")).toBeVisible();
await page.getByRole("button", { name: "关闭" }).click();
// 回书架 → 退出登录
await page.getByRole("link", { name: "书架" }).click();
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
await page.getByText("退出登录").click();
await expect(page).toHaveURL(/\/login/);
});
+29
View File
@@ -0,0 +1,29 @@
第一章 样本
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离,
因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及
当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用
明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按
章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是
搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云
收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合
端到端测试用的文本样本,昭然有效,勿改内容。
第二章 落雨
雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的
命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器
当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜
面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距,
本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读
主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中
列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行
至结尾,此章同样只包含一次标记词,合计两处命中。
第三章 风止
wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。
结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过
无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3,
即第二章标记已存。样本至此结束。
Binary file not shown.
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { request, type APIRequestContext } from "@playwright/test";
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url));
export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url));
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
export function creds(): { user: string; pass: string } {
const user = process.env.E2E_ADMIN_USER ?? process.env.ADMIN_USER;
const pass = process.env.E2E_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD;
if (!user || !pass) {
throw new Error(
"缺少管理员凭据:export E2E_ADMIN_USER/E2E_ADMIN_PASSWORD," +
"或 `set -a; source ../deploy/.env; set +a` 后重跑",
);
}
return { user, pass };
}
export async function adminApi(): Promise<APIRequestContext> {
const { user, pass } = creds();
const res = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: user, password: pass }),
});
if (!res.ok) throw new Error(`admin 登录失败:HTTP ${res.status}(dev 栈起了吗?凭据对吗?)`);
const { token } = (await res.json()) as { token: string };
return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
}
/** adminApi 登录带 429 退避重试:三 spec 并发 worker 的登录总数可能顶到后端 1min/5 次限流门,等窗口即可;仅消耗等待预算,不改用例语义 */
export async function adminApiRetry(): Promise<APIRequestContext> {
for (let i = 0; ; i++) {
try {
return await adminApi();
} catch (e) {
if (i > 20 || !(e instanceof Error) || !e.message.includes("HTTP 429")) throw e;
await new Promise((r) => setTimeout(r, 3000));
}
}
}
export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise<number> {
const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[];
const hit = libs.find((l) => l.name === name);
if (hit) return hit.id;
const created = (await (await ctx.post("/api/libraries", { data: { name } })).json()) as { id: number };
return created.id;
}
async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number; title: string } | null> {
const res = await ctx.get(`/api/books?q=${encodeURIComponent(q)}`);
const books = (await res.json()) as { id: number; title: string }[];
return books.find((b) => b.title.includes(q)) ?? null;
}
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
export async function ensureBook(
ctx: APIRequestContext,
libName: string,
fileName: string,
absPath: string,
): Promise<number> {
const title = fileName.replace(/\.[a-z]+$/i, "");
const existing = await findBook(ctx, title);
if (existing) return existing.id;
const libId = await ensureLibrary(ctx, libName);
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
multipart: {
file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) },
},
});
if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`);
const scan = await ctx.post(`/api/libraries/${libId}/scan`);
if (!scan.ok()) throw new Error(`触发扫描失败:HTTP ${scan.status()}`);
const deadline = Date.now() + 30_000;
for (;;) {
const hit = await findBook(ctx, title);
if (hit) return hit.id;
if (Date.now() > deadline) throw new Error(`扫描后 30s 内未找到书「${title}」`);
await new Promise((r) => setTimeout(r, 1000));
}
}
/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ);
}
+49
View File
@@ -0,0 +1,49 @@
import { expect, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
interface Waiver {
page: string;
rule: string;
reason: string;
}
/**
* critical/serious 违规的逐条豁免清单(④ 基建):
* ② 阅读器改版后旧 chrome 重写完毕,本期清空;新增豁免必须写明 reason 并控制在一次性条件。
*/
const WAIVERS: Waiver[] = [];
export async function scanA11y(page: Page, name: string): Promise<void> {
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
await page.evaluate(() =>
Promise.race([
Promise.allSettled(document.getAnimations().map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]),
);
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]).analyze();
if (results.violations.length > 0) {
console.log(
`[axe:${name}]`,
JSON.stringify(
results.violations.map((v) => ({
id: v.id,
impact: v.impact,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
null,
2,
),
);
}
const blocking = results.violations.filter(
(v) =>
(v.impact === "critical" || v.impact === "serious") && !WAIVERS.some((w) => w.page === name && w.rule === v.id),
);
expect(
blocking.map((v) => ({ id: v.id, impact: v.impact })),
`${name} 存在未豁免的 critical/serious a11y 违规`,
).toEqual([]);
}
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import { adminApiRetry, creds, ensureBook, FIXTURE_TXT } from "./helpers/api";
import { scanA11y } from "./helpers/axe";
let admin: APIRequestContext;
let bookId: number;
test.beforeAll(async () => {
admin = await adminApiRetry();
// 文件名避免与 CBZ 样本书同名:fixture 用 e2e-sample-txt.txt(title=e2e-sample-txt)
bookId = await ensureBook(admin, "e2e", "e2e-sample-txt.txt", FIXTURE_TXT);
});
test.afterAll(async () => {
await admin?.dispose();
});
test("登录 → txt 阅读 → 全书搜索 → 跳转", async ({ page }) => {
const { user, pass } = creds();
await page.goto("/login");
await page.fill("#login-user", user);
await page.fill("#login-pass", pass);
await page.getByRole("button", { name: "登录", exact: true }).click();
await expect(page).toHaveURL(/\/$/);
const card = page.locator(".group > button", { hasText: "e2e-sample-txt" });
await expect(card).toBeVisible();
await card.click();
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
// 导航抽屉 → 搜索 tab
await page.getByRole("button", { name: "导航", exact: true }).click();
const nav = page.locator("#reader-nav");
await expect(nav).toBeVisible();
await nav.getByRole("tab", { name: "搜索" }).click();
await nav.getByRole("textbox", { name: "搜索全书" }).fill("zebra-xc42");
// 大小写不敏感:第一章 2 处 + 第二章 1 处 = 3 行
await expect(nav.getByRole("listitem")).toHaveCount(3, { timeout: 10_000 });
expect(await nav.locator("mark").count()).toBe(3); // <mark> 高亮
// 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 2/3,抽屉关闭
await nav.getByRole("button").filter({ hasText: "第二章" }).first().click();
await expect(page.getByText("2/3")).toBeVisible({ timeout: 10_000 });
await expect(nav).not.toBeVisible();
await scanA11y(page, "reader-chrome");
// 设置面板冒烟:字号/行距即时生效 + 主题卡联动阅读面(data-rd 受控)
const pre = page.locator(".rd-surface pre");
await expect(pre).toHaveCSS("font-size", "20px");
await page.getByRole("button", { name: "更多阅读设置" }).click();
await page.getByRole("button", { name: "加大字号" }).click();
await expect(pre).toHaveCSS("font-size", "22px");
await page.getByRole("button", { name: "1.8", exact: true }).click();
await expect(pre).toHaveAttribute("style", /line-height: 1\.8/);
await page.getByRole("button", { name: "夜色主题" }).click();
await expect(page.locator(".rd-surface").first()).toHaveAttribute("data-rd", "night");
// 退出
await page.getByRole("link", { name: "书架" }).click();
await expect(page).toHaveURL(/\/$/);
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
await page.getByText("退出登录").click();
await expect(page).toHaveURL(/\/login/);
});
+36
View File
@@ -0,0 +1,36 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["dist/**", "dist-stats/**", "node_modules/**", "playwright-report/**", "test-results/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
// react-hooks 插件的 flat 导出名随版本而异(recommended-latest / configs.flat.recommended),
// 以安装版本实际导出为准,用 npx eslint --print-config src/main.tsx 验证规则已生效
reactHooks.configs.flat["recommended-latest"],
jsxA11y.flatConfigs.recommended,
{
files: ["**/*.{ts,tsx}"],
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
],
},
},
{
files: ["e2e/**/*.ts"],
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } },
rules: { "@typescript-eslint/no-explicit-any": "off" },
},
{
files: ["**/*.test.{ts,tsx}", "test/**/*.ts"],
// 测试代码允许 any 以贴合测试惯例(mock 数据、运行时探测)
rules: { "@typescript-eslint/no-explicit-any": "off" },
},
prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则
);
+2815 -30
View File
File diff suppressed because it is too large Load Diff
+26 -2
View File
@@ -6,9 +6,16 @@
"scripts": {
"test": "vitest run",
"dev": "vite",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"build": "tsc --noEmit && vite build",
"analyze": "ANALYZE=1 vite build",
"preview": "vite preview",
"check": "tsc --noEmit && vitest run && vite build"
"check": "tsc --noEmit && eslint . && prettier --check . && vitest run && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"keywords": [],
"author": "",
@@ -32,12 +39,29 @@
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.13.0",
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^22.20.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript/native": "npm:typescript@^7.0.2",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^30.0.1",
"prettier": "^3.9.6",
"rollup-plugin-visualizer": "^7.1.1",
"tailwindcss": "^4.3.3",
"typescript": "^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.70.0",
"vite": "^8.2.2",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^5.0.0"
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
timeout: 60_000,
use: {
baseURL: process.env.E2E_BASE_URL ?? "http://localhost:5173",
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
+16 -2
View File
@@ -25,8 +25,22 @@ export default function App() {
}
>
<Route path="/" element={<Shelf />} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
<Route
path="/admin/users"
element={
<RequireAdmin>
<AdminUsers />
</RequireAdmin>
}
/>
<Route
path="/admin/libraries"
element={
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
}
/>
</Route>
<Route
path="/book/:id"
+2 -3
View File
@@ -9,7 +9,7 @@ const mem = new Map<string, string>();
const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null;
return hasLS ? localStorage.getItem(TOKEN_KEY) : (mem.get(TOKEN_KEY) ?? null);
}
export function setToken(t: string | null): void {
@@ -181,8 +181,7 @@ export const api = {
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
patchBookmark: (id: number, note: string) =>
apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
patchBookmark: (id: number, note: string) => apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
listProgress: () => apiFetch<ProgressRow[]>("/progress"),
};
+2 -1
View File
@@ -40,6 +40,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
useEffect(() => {
if (token && meQ.isError) {
setToken(null); // 非 401 失败也要清持久化 token,避免与 React 状态分叉导致 RequireAuth 永久加载
// eslint-disable-next-line react-hooks/set-state-in-effect -- 对「me 查询失败」异步结果的清理反应,与持久化 token 需同帧回收;改渲染期重置/事件驱动会改变鉴权失败时序,保守保留
setTok(null);
}
}, [token, meQ.isError]);
@@ -77,7 +78,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
const { user, ready } = useAuth();
const loc = useLocation();
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
if (!ready || !user) return <div className="text-muted-foreground grid h-full place-items-center">加载中…</div>;
return <>{children}</>;
}
+38 -22
View File
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
@@ -55,8 +57,8 @@ export function BookmarksTab({
return (
<>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
<Input
className="min-w-0 flex-1"
placeholder="备注(可空)"
maxLength={500}
value={note}
@@ -65,26 +67,28 @@ export function BookmarksTab({
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button
className="rd-btn inline-flex shrink-0 items-center gap-1"
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="加书签"
disabled={add.isPending}
onClick={() => add.mutate()}
>
<Plus size={14} /> 加
</button>
<Plus size={14} />
</Button>
</div>
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
<Input
className="min-w-0 flex-1"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
@@ -92,30 +96,40 @@ export function BookmarksTab({
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn inline-flex shrink-0 items-center"
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="保存备注"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
<Check size={14} />
</button>
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}>
</Button>
<Button
variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="取消编辑"
onClick={() => setEditId(null)}
>
<X size={14} />
</button>
</Button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
title={b.note || undefined}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
@@ -123,14 +137,16 @@ export function BookmarksTab({
}}
>
<Pencil size={14} />
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
</Button>
<Button
variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
<X size={14} />
</button>
</Button>
</div>
)}
</li>
+26 -10
View File
@@ -1,19 +1,30 @@
import { useEffect, useState } from "react";
import type { Book } from "../api/types";
import { fetchObjectUrl } from "../lib/authImage";
import { btn, formatBadge } from "./ui";
import { FormatBadge } from "./format-badge";
import { Button } from "./ui/button";
import { TriangleAlert } from "lucide-react";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false });
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({
key: "",
src: "",
failed: false,
});
const [nonce, setNonce] = useState(0);
const key = (url ?? "") + "#" + nonce;
// url/nonce 变化时先落回占位态:React 官方推荐的「渲染期按下发 key 重置本地 state」,
// 替代原先 effect 内的同步 setState(级联渲染),行为等价——加载中同样呈现 src="" 的占位
const [prevKey, setPrevKey] = useState(key);
if (prevKey !== key) {
setPrevKey(key);
setState({ key, src: "", failed: false });
}
useEffect(() => {
if (!url) return;
let dead = false;
const k = url + "#" + nonce;
setState({ key: k, src: "", failed: false });
fetchObjectUrl(url)
.then((src) => !dead && setState({ key: k, src, failed: false }))
.catch(() => !dead && setState({ key: k, src: "", failed: true }));
@@ -30,16 +41,21 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url);
if (!book.cover_url || failed) {
return (
<div
className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}
>
<span className="select-none font-serif text-4xl text-muted-foreground">{Array.from(book.title)[0] ?? "?"}</span>
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
<div className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}>
<span className="select-none font-serif text-4xl text-muted-foreground">
{Array.from(book.title)[0] ?? "?"}
</span>
<FormatBadge fmt={book.format} className="absolute right-1.5 bottom-1.5" />
{failed && (
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
<Button
variant="secondary"
size="xs"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 gap-1"
onClick={retry}
>
<TriangleAlert className="size-4" />
封面重试
</button>
</Button>
)}
</div>
);
+5 -2
View File
@@ -13,8 +13,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
<div className="grid h-full place-items-center p-8 text-center">
<div>
<p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}>
<p className="mb-4 max-w-md text-sm break-all text-muted-foreground">{String(this.state.err)}</p>
<button
className="rounded-md bg-secondary px-3 py-1.5 text-sm"
onClick={() => this.setState({ err: null })}
>
重试
</button>
</div>
+5 -1
View File
@@ -36,7 +36,11 @@ export function Toaster() {
t.kind === "ok" ? "border-border" : "border-destructive/40"
}`}
>
{t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />}
{t.kind === "ok" ? (
<CheckCircle2 className="size-4 shrink-0 text-accent" />
) : (
<XCircle className="size-4 shrink-0 text-destructive" />
)}
{t.text}
</div>
))}
+60 -15
View File
@@ -4,7 +4,14 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { api } from "@/api/client";
import { useAuth } from "@/auth/AuthContext";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils";
@@ -12,7 +19,11 @@ import type { ReactNode } from "react";
function Brand() {
return (
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
<Link
to="/"
aria-label="BookLib 首页"
className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2"
>
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
<BookMarked className="size-4" />
</span>
@@ -57,7 +68,26 @@ export function AppShell() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
<DropdownMenuLabel>
{user?.username}
{isAdmin ? " · admin" : ""}
</DropdownMenuLabel>
{isAdmin && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>系统管理</DropdownMenuLabel>
<DropdownMenuItem asChild>
<Link to="/admin/libraries">
<FolderCog /> 库管理
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/admin/users">
<Users /> 用户管理
</Link>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={logout}>
<LogOut /> 退出登录
@@ -72,20 +102,25 @@ export function AppShell() {
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
<Brand />
<nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} />
{isAdmin && (
<>
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" active={loc.pathname.startsWith("/admin/libraries")} />
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" active={loc.pathname.startsWith("/admin/users")} />
</>
)}
<NavItem
to="/"
icon={<BookOpen className="size-4 shrink-0" />}
label="书架"
active={loc.pathname === "/" && !libParam}
/>
</nav>
{libs.length > 0 && (
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
<div className="min-h-0 flex-1 overflow-y-auto">
{libs.map((l) => (
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} />
<NavItem
key={l.id}
to={`/?lib=${l.id}`}
icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />}
label={l.name}
active={loc.pathname === "/" && libParam === String(l.id)}
/>
))}
</div>
</div>
@@ -110,10 +145,11 @@ export function AppShell() {
<Outlet />
</main>
{/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden">
<nav
aria-label="底部导航"
className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"
>
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
</nav>
</div>
</div>
@@ -122,7 +158,16 @@ export function AppShell() {
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
return (
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}>
<NavLink
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground",
isActive && "text-accent-strong",
)
}
>
{icon}
{label}
</NavLink>
+22
View File
@@ -0,0 +1,22 @@
import { Badge } from "@/components/ui/badge";
import type { Format } from "@/api/types";
const CLASSES: Record<Format, string> = {
cbz: "border-transparent bg-orange-500/15 text-orange-900 ring-0 dark:text-orange-300",
pdf: "border-transparent bg-rose-500/15 text-rose-900 ring-0 dark:text-rose-300",
epub: "border-transparent bg-sky-500/15 text-sky-900 ring-0 dark:text-sky-300",
txt: "border-transparent bg-emerald-500/15 text-emerald-900 ring-0 dark:text-emerald-300",
md: "border-transparent bg-teal-500/15 text-teal-900 ring-0 dark:text-teal-300",
};
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
return (
<Badge
variant="outline"
className={`text-[10px] font-semibold uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}
>
{fmt}
</Badge>
);
}
-15
View File
@@ -1,15 +0,0 @@
import type { SVGProps } from "react";
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}>
{children}
</svg>
);
}
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
<Svg {...p}>
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
</Svg>
);
-37
View File
@@ -1,37 +0,0 @@
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
export function RdSlider({
label,
ariaLabel,
min,
max,
value,
onChange,
}: {
label: string;
ariaLabel: string;
min: number;
max: number;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="px-2.5 pb-1.5 pt-2">
<div className="mb-1 text-sm">{label}</div>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
aria-label={ariaLabel}
className="rd-range"
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
{Array.from({ length: max - min + 1 }, (_, i) => (
<span key={i}>{min + i}</span>
))}
</div>
</div>
);
}
+31 -46
View File
@@ -1,66 +1,51 @@
import { Bookmark, List, X } from "lucide-react";
import { Bookmark, List, Search } from "lucide-react";
import type { ReactNode } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type NavTab = "toc" | "bm";
export type NavTab = "toc" | "bm" | "search";
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
/** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
export function ReaderNav({
tab,
onTab,
onClose,
toc,
bookmarks,
search,
}: {
tab: NavTab;
onTab?: (t: NavTab) => void;
onClose: () => void;
toc?: ReactNode;
bookmarks: ReactNode;
search?: ReactNode;
}) {
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
];
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
<nav
id="reader-nav"
aria-label="导航"
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
style={{ color: "var(--rd-fg,#c6c0b6)" }}
>
<div
role={toc != null ? "tablist" : undefined}
aria-label="导航选项卡"
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
>
{toc != null ? (
<>
<button
role="tab"
aria-selected={tab === "toc"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
onClick={() => onTab?.("toc")}
>
<List size={14} /> 目录
</button>
<button
role="tab"
aria-selected={tab === "bm"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
onClick={() => onTab?.("bm")}
>
<Bookmark size={14} /> 书签
</button>
</>
) : (
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
<Bookmark size={14} /> 书签
</span>
)}
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
<X size={14} />
</button>
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
<SheetHeader className="sr-only">
<SheetTitle>导航</SheetTitle>
</SheetHeader>
<div id="reader-nav" className="flex h-full flex-col">
<Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
<TabsList className="m-2 shrink-0 self-start">
{tabs.map(({ id, label, icon: Icon }) => (
<TabsTrigger key={id} value={id} className="gap-1.5">
<Icon size={14} /> {label}
</TabsTrigger>
))}
</TabsList>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
</Tabs>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
</nav>
</>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,76 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect } from "vitest";
import { beforeEach, describe, it } from "vitest";
import { ReaderSettings } from "@/components/reader-settings";
import { ThemeProvider } from "@/components/theme";
beforeEach(() => {
localStorage.clear();
});
function renderSettings(props = {}) {
return render(
<ThemeProvider>
<ReaderSettings
slider={{ value: 2, max: 10, onChange: () => {}, ariaLabel: "页码跳转" }}
position="3/12 · 25%"
{...props}
/>
</ThemeProvider>,
);
}
describe("ReaderSettings", () => {
it("常驻行渲染:滑条 aria-label 与位置文本", () => {
renderSettings();
expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument();
expect(screen.getByText("3/12 · 25%")).toBeInTheDocument();
});
it("主题卡点击 → localStorage 的 themeMode 变手动值", () => {
renderSettings();
fireEvent.click(screen.getByRole("button", { name: "夜色主题" }));
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
expect(stored.themeMode).toBe("night");
});
it("「跟随全局」按钮存在且点击回 auto", () => {
renderSettings();
const follow = screen.getByRole("button", { name: "跟随全局" });
expect(follow).toBeInTheDocument();
expect(follow).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByRole("button", { name: "米色主题" }));
expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("button", { name: "跟随全局" }));
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
expect(stored.themeMode).toBe("auto");
});
it("showFont=false 时无 A−/A+", () => {
renderSettings({ showFont: false, onFont: () => {} });
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument();
});
it("未传 onFont 时 A−/A+ 隐藏(showFont 也无字号钮)", () => {
renderSettings({ showFont: true });
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
});
it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("extra")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("extra")).toBeInTheDocument();
});
it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => {
let got = 0;
renderSettings({ showFont: true, onFont: (d: number) => (got = d) });
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
expect(got).toBe(1);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { ChevronDown, ChevronUp } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useGlobalDark } from "@/components/theme";
import {
RD_THEMES,
RD_THEME_LABEL,
FONT_SIZES,
resolveRdTheme,
useReaderPrefs,
type RdTheme,
type ReaderPrefs,
} from "@/lib/readerPrefs";
export interface ReaderSettingsProps {
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数;EPUB 无全局分数故可省 */
slider?: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
/** 位置文本,如 "3/12 · 25%" */
position?: string;
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
left?: ReactNode;
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
showFont?: boolean;
/** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */
onFont?: (d: number) => void;
/** 当前字号档位(用于 A−/A+ 边界禁用);不传按文本框位 */
fontIdx?: number;
/** 受控主题:传入时用调用方状态,保证阅读面 data-rd 与设置栏同步(不传则组件内自持) */
themeMode?: ReaderPrefs["themeMode"];
onThemeMode?: (m: ReaderPrefs["themeMode"]) => void;
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
extra?: ReactNode;
}
/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
export function ReaderSettings({
slider,
position,
left,
showFont = false,
onFont,
fontIdx,
themeMode,
onThemeMode,
extra,
}: ReaderSettingsProps) {
const internal = useReaderPrefs();
// 主题受控:调用方给状态时用调用方(阅读面 data-rd 联动);否则退回组件内自持(独立使用/测试)
const mode = themeMode ?? internal.prefs.themeMode;
const setMode = onThemeMode ?? internal.setThemeMode;
const globalDark = useGlobalDark();
const rd = resolveRdTheme(mode, globalDark);
const [open, setOpen] = useState(false);
return (
<div
className="border-t bg-background/95 text-foreground backdrop-blur pt-1.5 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
data-rd={rd}
>
<div className="flex items-center gap-2.5 px-2.5">
{left && <div className="flex items-center gap-1.5">{left}</div>}
{position && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground" aria-live="polite">
{position}
</span>
)}
<div className="flex items-center gap-1.5 whitespace-nowrap" role="group" aria-label="阅读主题">
{RD_THEMES.map((t) => (
<button
key={t}
aria-label={`${RD_THEME_LABEL[t]}色主题`}
aria-pressed={mode === t}
onClick={() => setMode(t)}
className="h-5 w-5 shrink-0 rounded-full transition-transform hover:scale-110"
style={{
background: SWATCH[t],
boxShadow:
rd === t
? "0 0 0 2px var(--rd-accent)"
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
}}
/>
))}
<Button
variant="ghost"
size="xs"
aria-pressed={mode === "auto"}
className="text-muted-foreground"
onClick={() => setMode("auto")}
>
跟随全局
</Button>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{showFont && onFont && (
<>
<Button
variant="ghost"
size="icon-xs"
aria-label="减小字号"
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === 0}
onClick={() => onFont(-1)}
>
A−
</Button>
<Button
variant="ghost"
size="icon-xs"
aria-label="加大字号"
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === FONT_SIZES.length - 1}
onClick={() => onFont(1)}
>
A+
</Button>
</>
)}
{extra && (
<Button
variant="ghost"
size="icon-xs"
aria-label="更多阅读设置"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
</Button>
)}
</div>
</div>
{slider && (
<Slider
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
value={[slider.value]}
max={slider.max}
onValueChange={([v]) => slider.onChange(v)}
aria-label={slider.ariaLabel}
/>
)}
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
</div>
);
}
/** 设置行通用骨架:label + 右侧控件组。 */
export function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="flex items-center gap-1">{children}</span>
</div>
);
}
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { expect } from "vitest";
import { beforeEach, describe, it } from "vitest";
import { ReadingStatsCard } from "@/components/reading-stats-card";
import type { StatsStore } from "@/lib/readingStats";
function fakeStore(): StatsStore {
const today = new Date();
const p = (n: number) => String(n).padStart(2, "0");
const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
const y = new Date(today);
y.setDate(y.getDate() - 1);
const buckets: Record<string, number> = { [key(today)]: 900, [key(y)]: 300 };
return {
record() {},
weekly() {
const perDay = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today);
d.setDate(d.getDate() - (6 - i));
const k = key(d);
return { day: k, seconds: buckets[k] ?? 0 };
});
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
},
streak() {
return 2;
},
};
}
beforeEach(() => localStorage.clear());
describe("ReadingStatsCard", () => {
it("本周文案含时长与连续天数", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByText(/本周阅读/)).toBeInTheDocument();
expect(screen.getByText(/20 分钟/)).toBeInTheDocument();
expect(screen.getByText(/连续 2 天/)).toBeInTheDocument();
});
it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => {
render(<ReadingStatsCard store={fakeStore()} />);
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
const btn = screen.getByRole("button", { name: "折叠统计" });
expect(screen.queryAllByRole("img").length).toBeGreaterThan(0);
fireEvent.click(btn);
expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument();
expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "展开统计" }));
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
});
it("注入的 store 无连续记录时不显示连续段", () => {
const s = fakeStore();
render(<ReadingStatsCard store={{ ...s, streak: () => 0 }} />);
expect(screen.queryByText(/连续/)).not.toBeInTheDocument();
});
it("7 根柱(含 0 值日)", () => {
render(<ReadingStatsCard store={fakeStore()} />);
const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" });
expect(chart.children.length).toBe(7);
});
});
@@ -0,0 +1,58 @@
import { useMemo, useState } from "react";
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
const FOLD_KEY = "stats-card-folded";
function fmtDur(s: number): string {
const h = Math.floor(s / 3600);
const m = Math.round((s % 3600) / 60);
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
}
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
const stats = useMemo(() => store ?? createLocalStats(), [store]);
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
const { perDay, total } = stats.weekly();
const streak = stats.streak();
const max = Math.max(1, ...perDay.map((d) => d.seconds));
return (
<section aria-label="阅读统计" className="bg-card text-card-foreground px-4 py-3 text-sm rounded-lg border">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<span>
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
</span>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label={folded ? "展开统计" : "折叠统计"}
onClick={() => {
const v = !folded;
setFolded(v);
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
}}
>
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
</Button>
</div>
{!folded && (
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
{perDay.map((d) => (
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
<div
className="bg-primary/70 w-full rounded-sm"
style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }}
/>
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
</div>
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,23 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { ThemeProvider } from "./theme";
import { ThemeToggle } from "./theme-toggle";
describe("ThemeToggle", () => {
it("switches theme via dropdown, persists to localStorage, toggles dark class", async () => {
localStorage.clear();
document.documentElement.classList.remove("dark");
render(
<ThemeProvider>
<ThemeToggle />
</ThemeProvider>,
);
await userEvent.click(screen.getByRole("button", { name: "切换主题" }));
await userEvent.click(await screen.findByText("深色"));
await waitFor(() => expect(localStorage.getItem("ui.theme")).toBe("dark"));
expect(document.documentElement.classList.contains("dark")).toBe(true);
});
});
+13 -2
View File
@@ -1,6 +1,11 @@
import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useTheme, type Theme } from "./theme";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
@@ -15,7 +20,13 @@ export function ThemeToggle() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
{theme === "dark" ? (
<Moon className="size-4" />
) : theme === "light" ? (
<Sun className="size-4" />
) : (
<Monitor className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
+16
View File
@@ -45,3 +45,19 @@ export function useTheme() {
if (!v) throw new Error("useTheme outside ThemeProvider");
return v;
}
const MQ = "(prefers-color-scheme: dark)";
/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */
export function useGlobalDark(): boolean {
const { theme } = useTheme();
const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches);
useEffect(() => {
if (theme !== "system") return;
const mq = matchMedia(MQ);
const on = () => setSysDark(mq.matches);
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, [theme]);
return theme === "dark" || (theme === "system" && sysDark);
}
-19
View File
@@ -1,19 +0,0 @@
import type { Format } from "../api/types";
export const btn =
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
export const btnGhost =
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
export const FORMAT_BADGE: Record<Format, string> = {
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
};
export function formatBadge(fmt: Format, extra = ""): string {
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
}
+35 -68
View File
@@ -1,47 +1,34 @@
"use client"
"use client";
import * as React from "react"
import { cn } from "@/lib/utils"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogContent({
@@ -49,7 +36,7 @@ function AlertDialogContent({
size = "default",
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm"
size?: "default" | "sm";
}) {
return (
<AlertDialogPortal>
@@ -59,60 +46,51 @@ function AlertDialogContent({
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className
className,
)}
{...props}
/>
</AlertDialogPortal>
)
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogDescription({
@@ -125,23 +103,20 @@ function AlertDialogDescription({
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className
className,
)}
{...props}
/>
)
);
}
function AlertDialogAction({
@@ -153,13 +128,9 @@ function AlertDialogAction({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
<AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
</Button>
)
);
}
function AlertDialogCancel({
@@ -171,13 +142,9 @@ function AlertDialogCancel({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
<AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
</Button>
)
);
}
export {
@@ -193,4 +160,4 @@ export {
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
};
+13 -21
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Slot } from "radix-ui";
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
@@ -9,12 +9,10 @@ const badgeVariants = cva(
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
@@ -22,26 +20,20 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span";
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
<Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
@@ -0,0 +1,31 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button", () => {
it("renders label, exposes variant/size data attrs, fires onClick", async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>保存</Button>);
const btn = screen.getByRole("button", { name: "保存" });
expect(btn).toHaveAttribute("data-variant", "default");
expect(btn).toHaveAttribute("data-size", "default");
await userEvent.click(btn);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("applies non-default variant/size and blocks clicks when disabled", async () => {
const onClick = vi.fn();
render(
<Button variant="outline" size="sm" disabled onClick={onClick}>
x
</Button>,
);
const btn = screen.getByRole("button");
expect(btn).toHaveAttribute("data-variant", "outline");
expect(btn).toHaveAttribute("data-size", "sm");
expect(btn).toBeDisabled();
await userEvent.click(btn);
expect(onClick).not.toHaveBeenCalled();
});
});
+12 -14
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Slot } from "radix-ui"
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Slot } from "radix-ui";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -13,10 +13,8 @@ const buttonVariants = cva(
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
@@ -34,8 +32,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -45,9 +43,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button"
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
@@ -57,7 +55,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+14 -50
View File
@@ -1,17 +1,14 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,72 +17,39 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
@@ -0,0 +1,27 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "./dialog";
describe("Dialog", () => {
it("opens via trigger, renders title, closes via Escape (radix under jsdom)", async () => {
render(
<Dialog>
<DialogTrigger>打开对话框</DialogTrigger>
<DialogContent>
<DialogTitle>测试标题</DialogTitle>
<p>正文内容</p>
</DialogContent>
</Dialog>,
);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "打开对话框" }));
expect(await screen.findByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("测试标题")).toBeInTheDocument();
expect(screen.getByText("正文内容")).toBeInTheDocument();
await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
});
+28 -48
View File
@@ -1,48 +1,37 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import * as React from "react";
import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
className,
)}
{...props}
/>
)
);
}
function DialogContent({
@@ -51,7 +40,7 @@ function DialogContent({
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -60,7 +49,7 @@ function DialogContent({
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
className,
)}
{...props}
>
@@ -76,7 +65,7 @@ function DialogContent({
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +75,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
);
}
function DialogFooter({
@@ -95,15 +84,12 @@ function DialogFooter({
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
>
{children}
@@ -113,33 +99,27 @@ function DialogFooter({
</DialogPrimitive.Close>
)}
</div>
)
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
);
}
export {
@@ -153,4 +133,4 @@ export {
DialogPortal,
DialogTitle,
DialogTrigger,
}
};

Some files were not shown because too many files have changed in this diff Show More