Compare commits

139 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
XingfenD 3178c2802f refactor(web): extract prefetch slider into reusable RdSlider component 2026-09-08 21:31:20 +08:00
XingfenD 472e58bec9 fix(web): unify 更多 menu text size, add 0-3 tick anchors under prefetch slider 2026-09-08 21:30:26 +08:00
XingfenD d2c852d6f0 feat(web): iconify reader chrome, move 连读/预读 into 更多 menu with slider 2026-09-08 21:29:01 +08:00
XingfenD 9c2b7e2074 feat(web): collapse reader toc/bookmark buttons into single 导航 toolbar button 2026-09-08 21:11:16 +08:00
XingfenD 65e07a0890 chore: ignore tsc buildinfo artifact 2026-09-08 21:08:52 +08:00
XingfenD 48161ea1ff feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs 2026-09-08 21:08:42 +08:00
XingfenD 4f4c7e5434 chore: gitignore playwright artifacts 2026-09-08 20:48:38 +08:00
XingfenD 6fbe3b0794 fix(web): active lib highlight, rail a11y names, card focus ring, AA accent + destructive tokens, touch-reachable menu, brand wordmark, chrome unify 2026-09-08 20:37:20 +08:00
XingfenD 7e0847815d docs(changelog): phase-1 modern redesign — shadcn tokens, dual theme, app shell, rebuilt public surfaces 2026-09-08 19:37:38 +08:00
XingfenD 7727baa25b fix(web): theme menu items fire on keyboard select (onSelect) 2026-09-08 19:19:45 +08:00
XingfenD dcaf502de6 feat(ui): admin pages on shadcn table/card/alert-dialog; drop TopBar, trim icons/ui.ts to reader-compat surface 2026-09-08 19:14:41 +08:00
XingfenD 273f35a42f fix(web): shelf search debounce merges against live searchParams (no lost sort/group clicks) 2026-09-08 19:08:05 +08:00
XingfenD b1c0db6c03 feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete 2026-09-08 18:58:16 +08:00
XingfenD d9e9e95ece feat(ui): login rebuilt on shadcn card/label/input, theme toggle available pre-auth 2026-09-08 17:47:17 +08:00
XingfenD 52e95a48d8 fix(ui): gate app sidebar to md+ and use onSelect for logout (keyboard path) 2026-09-08 17:41:29 +08:00
XingfenD 7957e5cee5 feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle 2026-09-08 17:35:14 +08:00
XingfenD 913f3286bb feat(lib): shelf sort/section pure helpers + formatSize extraction, table tests 2026-09-08 17:29:07 +08:00
XingfenD 03bc537308 feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover 2026-09-08 17:17:44 +08:00
XingfenD 87b7545a94 feat(ui): shadcn/radix toolchain — deps, @ alias, cn, generated primitives 2026-09-08 17:07:28 +08:00
XingfenD ee676c2d2e docs(plan): modern redesign phase-1 implementation plan — 8 tasks from toolchain to acceptance walkthrough 2026-09-08 17:02:02 +08:00
XingfenD b8a5b3deb6 docs(design): phase-1 modern redesign spec — shadcn/radix foundation, dual theme, app shell, shelf/admin rebuild; readers deferred to phase 2 2026-09-08 16:49:46 +08:00
XingfenD 2721df8ea9 fix(web): upload toast always showed 0 files (live FileList read after input reset); snapshot before upload 2026-09-08 16:22:12 +08:00
XingfenD e5dacc0a77 fix(cbz): clamp locked cur at chapter end, hide 连读 on flat packs, silent restore, tighten prefetch storage 2026-09-08 15:28:27 +08:00
XingfenD 60c4ccf26f fix(cbz): window-relative scroll geometry locks head boundary; default prefetch 2 when key absent 2026-09-08 15:15:42 +08:00
XingfenD f63b639f64 feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes 2026-09-08 15:01:28 +08:00
XingfenD cd251b940c feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests 2026-09-08 14:56:46 +08:00
XingfenD bba82e5529 docs(plan): correct container names to podman compose underscores 2026-09-08 14:54:34 +08:00
XingfenD 497dc16604 fix(deploy): COPY paths in web Dockerfiles still referenced old web/ dir after web->frontend rename — dev image build failed 2026-09-08 14:53:29 +08:00
XingfenD 37ddff89a5 docs(cbz): chapter-scoped reading implementation plan — 3 tasks, virt helpers TDD first 2026-09-08 14:51:03 +08:00
XingfenD a39ddf7b57 docs: add cbz chapter-scoped reading design spec 2026-09-08 14:45:15 +08:00
XingfenD ec3f2c587e feat(reader): remove admin delete button from reader header; shelf card hover remains the only delete entry 2026-09-08 13:08:46 +08:00
XingfenD a43b73dcef fix(cbz): attach resize observer via callback ref + drop vh from scroll-mode geometry deps — fixes constant per-page gaps and refetch storm on mobile 2026-09-08 13:05:58 +08:00
XingfenD 32b9d3a136 feat(ui): content-first shelf redesign — responsive cover grid, hover overlays, format badges, skeletons, sticky blurred top bar, brand login, SVG icon set, animated toasts 2026-09-08 13:05:40 +08:00
XingfenD 02ac3e5f7f Merge branch 'feat/bookmarks' 2026-09-08 12:33:32 +08:00
XingfenD 40e9999e6a chore(bookmarks): changelog + e2e verification (create/jump/patch/delete against live api, seek page serves real jpeg) 2026-09-08 12:32:36 +08:00
XingfenD d989668650 feat(bookmarks): shared useBookmarks panel + wired into all four readers 2026-09-08 01:06:39 +08:00
XingfenD b2c5c4a445 feat(bookmarks): client plumbing — saver capture + bookmark api 2026-09-08 01:04:44 +08:00
XingfenD 0fe3bc3057 feat(bookmarks): api+store — per-user CRUD, owner-scoped 404, percent-ordered list 2026-09-08 01:03:44 +08:00
XingfenD e117ad52d3 docs(bookmarks): implementation plan — 4 tasks, backend vertical slice first, shared useBookmarks panel wired into all readers 2026-09-08 00:59:46 +08:00
XingfenD 928e52c604 docs(bookmarks): design spec — per-user bookmarks table, REST CRUD with owner-scoped 404s, capture/seek reuse of progress locators, shared sidebar component 2026-09-08 00:40:00 +08:00
XingfenD aa18eb66eb Merge branch 'feat/cbz-chapters' 2026-09-08 00:24:40 +08:00
XingfenD 66466ec8d1 Merge branch 'feat/chunked-upload' 2026-09-08 00:24:40 +08:00
XingfenD b8da9fd92e feat(cbz): adapt reader to real archive structure — PageIndex skips macOS junk (__MACOSX/ and ._ AppleDouble were polluting pages with 163-byte blacks), pages endpoint derives chapters[{title,start}] from folder layout, imageless zip now lands state=error instead of an empty reader, CbzReader gains 目录 sidebar + prev/next chapter (TextReader pattern, rd-* classes); pagesidx cache key bumped for one-time invalidation; e2e on 311MB 調教開關:第二季.zip: 15048→7524 real pages, 52 chapters, all JPEGs 2026-09-08 00:22:13 +08:00
XingfenD 7d650107d2 feat(upload): resumable chunked upload protocol for large files — init(parts≤32MB)/status/complete endpoints with fingerprint-derived deterministic uploadId (same file resumes, no restart), sessions in BOOKS_DIR/.uploads with 24h sweep, frontend uploadBook auto-switches >16MB to 8MB parts (retry x3 in place, skip received); oversize single POST now reports honest 413 too_large instead of "multipart field 'file' required" (was MaxBytesReader abort mislabeled); UPLOAD_MAX_MB wired into both compose stacks (cap total, .env.example 2048), nginx body limit drops to 32m; testCfg limit 1MB, CJK/fullwidth-colon filename proven unaffected; e2e verified with 311MB real file (39 parts, sha1 match) 2026-09-07 23:56:40 +08:00
XingfenD aba1e58fa6 Merge branch 'feat/reader-warm-theme' 2026-09-07 23:18:14 +08:00
XingfenD c6dfb92c76 fix(cbz): smooth scrollbar/slider dragging — rAF-coalesce scroll handler, idle-debounced prefetch, frame-batched height corrections, PageHeights.setEst converges unseen pages to the first measured page size, page slider seeks instantly while scrubbing (no per-notch smooth-animation restart); restore lost ### Fixed header in changelog 2026-09-07 23:13:04 +08:00
XingfenD 065fde5b56 feat(reader): immersive chrome + WeChat-Read/Mihon-style bottom sheets — tap-center toggles header/toolbar, progress & page sliders seek anywhere, WYSIWYG theme swatches, A± font size; CBZ gains 连读/整页/适高 modes, edge-tap & arrow-key paging, persisted mode (seekChapter helper + test) 2026-09-07 23:03:26 +08:00
XingfenD 7e63c21902 feat(reader): stop detecting 卷 in txt splitting (volume lines stay body text, volume-only books fall back to pseudo-sections); replace chapter dropdown with a 目录 sidebar overlay (active row highlighted + centered, click-away/pick to close) 2026-09-07 22:42:10 +08:00
XingfenD 7fb01ce335 feat(ui): warm library palette (zinc+emerald -> stone+amber) with hover/focus feedback; text reader gains paper/sepia/night themes + persisted font-size (lib/readerPrefs.ts), serif justified measure; PDF drops emoji icons for 首页/末页, CBZ pill page counter, EPUB centered spread, blur reader header with format badge, aria labels on login/search/chapter select 2026-09-07 22:37:47 +08:00
XingfenD 2478da059b Merge branch 'fix/txt-chapters-render' 2026-09-07 22:23:32 +08:00
XingfenD f47f2a6199 fix(reader): split large txt into chapters (章 as boundaries, 卷 as optgroup labels, pseudo-sections when unmarked) rendered one at a time so the browser stops freezing; decode via strict-UTF-8-then-GB18030 with UTF-16 BOM sniffing to kill mojibake; progress locator {ch,scrollFraction} with old global-fraction fallback 2026-09-07 22:21:29 +08:00
XingfenD 991de0ef26 Merge branch 'feat/server-generated-library-root' 2026-09-07 21:59:48 +08:00
XingfenD 45eefd6741 feat(libs): create library by name only; root_path derived server-side as BOOKS_DIR/SafeName(name), validated at creation; drop root_path input from admin UI; align changelogs to bilingual template; trim AGENTS.md to rules not derivable from repo layout 2026-09-07 21:56:59 +08:00
XingfenD ca64bc0d73 refactor(repo): conform structure to AGENTS.md (web->frontend, internal/api->cmd/webui/{api,handlers}, docs/README+CHANGELOGs, module .gitignores, drop stray library/ and committed debug bins) 2026-09-07 21:37:30 +08:00
XingfenD 961de429f9 fix(deploy): pin distinct image tags per compose (booklib/{api,web}:{dev,prod}); same project name shared default tags so dev up without --build ran the prod image 2026-09-07 21:24:33 +08:00
XingfenD 95317a0108 docs(readme): rewrite run/dev/migrate instructions for dev/release deploy spec
- scripts/smoke.sh: idempotent re-run (reuse existing member/library on 409)
- web client.ts: probe localStorage methods, not typeof (node 22+ stub regression)
2026-09-07 21:09:11 +08:00
XingfenD 0de824cc6a feat(deploy): dev compose runs full stack with source mounts, delve, shared volumes; vite proxy target via env (dlv --accept-multiclient, GOPROXY=goproxy.cn in dev image) 2026-09-07 21:04:40 +08:00
XingfenD 0ea918fae6 feat(deploy): release compose under deploy/ with shared named volumes and rendered config mounts; drop root compose 2026-09-07 20:55:57 +08:00
XingfenD 67dc4bc2bf feat(deploy): dev/prod Dockerfiles in service dirs (backend/,web/), templates live beside rendered outputs, entrypoint copies /etc/booklib conf before resolver injection 2026-09-07 20:53:41 +08:00
XingfenD a78442da54 feat(deploy): prepare.sh + config templates, migrate env to deploy/, gitignore rendered outputs 2026-09-07 20:18:12 +08:00
XingfenD 505f660dbd fix: self-heal missing library root on access; accept .zip in admin upload picker 2026-09-07 20:16:26 +08:00
XingfenD 6b3a4e5240 docs(plan): docker deploy conformance implementation plan (5 tasks); spec: smoke storage path item 2026-09-07 19:42:39 +08:00
XingfenD e563c88913 docs(spec): docker deployment conformance redesign (dev/release compose, prepare.sh, shared volumes, storage path) 2026-09-07 19:36:34 +08:00
221 changed files with 24435 additions and 2483 deletions
+8 -4
View File
@@ -1,8 +1,12 @@
.env
.git
library/
.superpowers/
deploy/logs/
deploy/nginx/nginx.conf
deploy/nginx/conf.d/default.conf
deploy/redis/redis.conf
deploy/api/storage/
backend/booklib*
web/node_modules
web/dist
web/dev-dist
frontend/node_modules
frontend/dist
frontend/dev-dist
-4
View File
@@ -1,4 +0,0 @@
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
+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
+6 -2
View File
@@ -1,4 +1,8 @@
.env
library/
.superpowers/
backend/server
tasks/
.playwright*/
frontend/tsconfig.tsbuildinfo
playwright-report/
test-results/
dist-stats/
+35
View File
@@ -0,0 +1,35 @@
# AGENTS.md
## Safety Rules
- Dev branch naming: `{feat|fix|docs|chore}/{branch-name}` (e.g. `feat/file-tag-done`, `fix/tree-render`).
- Before `git commit`: run `git branch --show-current`. If on `master`, do NOT commit — ask user for a branch name (suggest one based on the changes), create it, commit there.
- Every user-visible change gets a changelog entry: WebUI changes (files under `frontend/`) → `docs/CHANGELOG_web.md`, everything else → `docs/CHANGELOG.md`; never duplicate an entry across both. Higher versions on top.
- CHANGELOG entry format: same entry has English line then Chinese line on consecutive lines (no blank line between them); different entries are separated by a blank line.
- `docs/README.md` and `docs/README_zh.md` stay content-equivalent; update them in the same change.
## Docker deployment (dev & release)
- dev = `deploy/docker-compose.dev.yml`: source bind-mounted (`../backend:/app`, `../frontend:/app` with an anonymous volume on `/app/node_modules`), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (delve on 2345), web runs `npm run dev` (hot reload, no rebuild needed); infra ports exposed to host (postgres 5432, redis 6379); startup gated on healthchecks.
- release = `deploy/docker-compose.yml`: multi-stage `*.prod` Dockerfiles (`target: runner`) with compiled binaries, no source mounts; infra ports stay on the internal network only.
- Persisted state lives only in named volumes `booklib_{postgres,redis}_data` — same names in both files, so dev and release see the same data. Only `down -v` wipes them.
- Books on disk: host `deploy/api/storage` is mounted as `/data/books` (`BOOKS_DIR`); uploads land there and the scanner picks them up.
- Rendered configs `deploy/nginx/nginx.conf`, `deploy/nginx/conf.d/default.conf`, `deploy/redis/redis.conf` (mounted `:ro`) come from their `*.tpl` via `deploy/prepare.sh` — edit the template, rerun `prepare.sh`, restart; stale files in a container mean you forgot to rerun. Host logs under `deploy/logs/`.
- Tear down with `down`, not `rm`, or the web image's anonymous `node_modules` volume gets orphaned.
## Project structure
Root: `docs` (docs + changelogs), `backend` (Go webui service), `frontend` (node app, served via nginx in release), `deploy` (compose stacks, rendered configs, host logs). Actual layout is visible via `ls`; only rules not derivable from it are listed here.
### backend
- `cmd/`: each subdir is exactly one `main` package; `main.go` only bootstraps (load config, wire deps, serve, graceful shutdown) — no business logic, and nothing outside `cmd` imports `cmd`.
- `cmd/webui/api` is the single source of truth for the endpoint contract; the frontend aligns with it.
- Handlers stay thin (bind/validate, call `internal`, map errors) — never touch the DB or implement domain rules; SQL and business rules live behind `internal` boundaries.
- Business code defaults to `internal/`; `pkg/` is opt-in public surface shared with other repos — keep it small, never leak `internal` types through it.
### frontend
- 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.
- Service-specific host dirs (storage, config) go under `deploy/{$service_name}/`, never loose at the repo root.
-53
View File
@@ -1,53 +0,0 @@
# Book & Comic Library
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`。
## 跑起来(生产形态)
```bash
cp .env.example .env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
docker compose up -d --build
./scripts/smoke.sh # 端到端验收(登录、建库、上传、扫描、进度、删除、immutable 头)
```
- web: `http://localhost:8080`,API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
- 原始书放在 `./library/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
## 可信代理与限流
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
## 开发 / 测试
```bash
docker compose -f deploy/docker-compose.dev.yml up -d # PG :5433, Redis :6380(避开本机默认端口)
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5433/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6380'
go vet ./... && gofmt -l .
go test -p 1 -count=1 ./...
```
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。
无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
## 改 schema 前必读
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
## 前端开发(web/)
- `cd web && npm install`;`npm run dev`(:5173,`/api` 代理到 :8080)。
- 后端起法:`docker compose -f deploy/docker-compose.dev.yml up -d --wait` 起 PG(:5433)/Redis(:6380),
然后 `cd backend && DATABASE_URL=postgres://lib:lib@localhost:5433/lib?sslmode=disable \
REDIS_URL=redis://localhost:6380 JWT_SECRET=dev go run ./cmd/server`。
- 门槛:`npm run check`(tsc + vitest + vite build)。
## 生产部署(含前端)
- `.env` 配 `JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD` 后 `docker compose up -d --build`;
打开 http://localhost:8080(web=SPA+nginx,/api 反代 api:8080)。
- PWA:不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
- 冒烟:`bash scripts/smoke.sh`(后端直连)、`bash scripts/smoke-web.sh`(经 nginx 全栈,需 :8080 空闲)。
+3
View File
@@ -0,0 +1,3 @@
__debug_bin*
server
booklib*
+10
View File
@@ -0,0 +1,10 @@
FROM golang:1.26 AS base
WORKDIR /app
FROM base AS dev
# 源码由 compose 挂载进 /app;镜像只带工具链 + delve
# 国内直连 proxy.golang.org 会超时,goproxy.cn 走直连
ENV GOPROXY=https://goproxy.cn,direct
RUN go install github.com/go-delve/delve/cmd/dlv@latest
EXPOSE 8080 2345
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"]
+15
View File
@@ -0,0 +1,15 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/webui
FROM alpine:3.20 AS runner
RUN adduser -D -H app
COPY --from=build /server /server
# /data/books 由宿主 bind(./api/storage)覆盖;/data 下目录预建并授权,兼容 podman
RUN mkdir -p /data && chown app:app /data
USER app
EXPOSE 8080
ENTRYPOINT ["/server"]
+63
View File
@@ -0,0 +1,63 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"booklib/cmd/webui/handlers"
"booklib/internal/config"
"booklib/internal/ports"
)
// 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, users, libs, books, progress, bookmarks, rl, sc, med, up)
r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e)
}
r.Use(gin.Recovery())
g := r.Group("/api")
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
g.POST("/auth/login", h.Login)
p := g.Group("", h.AuthMw())
p.GET("/auth/me", h.Me)
usersGrp := p.Group("/users", h.AdminOnly())
usersGrp.GET("", h.ListUsers)
usersGrp.POST("", h.CreateUser)
usersGrp.DELETE("/:id", h.DeleteUser)
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)
uploads.POST("/:uid/complete", h.UploadComplete)
uploads.PUT("/:uid/parts/:index", h.UploadPart)
p.GET("/books", h.ListBooks)
p.GET("/books/:id", h.GetBook)
p.DELETE("/books/:id", h.AdminOnly(), h.DeleteBook)
p.GET("/books/:id/cover", h.ServeCover)
p.GET("/books/:id/file", h.ServeFile)
p.GET("/books/:id/pages", h.PagesCount)
p.GET("/books/:id/pages/:n", h.Page)
p.PUT("/books/:id/progress", h.PutProgress)
p.GET("/progress", h.ListProgress)
p.GET("/books/:id/bookmarks", h.ListBookmarks)
p.POST("/books/:id/bookmarks", h.CreateBookmark)
p.PATCH("/bookmarks/:id", h.PatchBookmark)
p.DELETE("/bookmarks/:id", h.DeleteBookmark)
return r
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"booklib/internal/config"
)
func testCfg() *config.Config {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func TestHealthz(t *testing.T) {
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)
if w.Code != http.StatusOK {
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)
}
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -14,17 +14,17 @@ import (
const loginWindow = time.Minute
const loginMax = 5
func (a *api) login(c *gin.Context) {
func (h *H) Login(c *gin.Context) {
var req struct{ Username, Password string }
if c.ShouldBindJSON(&req) != nil || req.Username == "" || req.Password == "" {
err(c, http.StatusBadRequest, "bad_request", "username and password required")
return
}
if n := a.rdb.IncrWindow(c, "loginrl:"+c.ClientIP(), loginWindow); n > loginMax {
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 := a.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)
@@ -39,7 +39,7 @@ func (a *api) login(c *gin.Context) {
err(c, http.StatusUnauthorized, "unauthorized", "bad credentials")
return
}
tok, serr := auth.Sign(a.cfg.JWTSecret, u.ID, u.Role)
tok, serr := auth.Sign(h.cfg.JWTSecret, u.ID, u.Role)
if serr != nil {
err(c, http.StatusInternalServerError, "internal", "sign")
return
@@ -47,11 +47,16 @@ func (a *api) login(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"token": tok})
}
func (a *api) me(c *gin.Context) {
u, qerr := a.st.GetUserByID(c, uid(c))
func (h *H) Me(c *gin.Context) {
// B7: only no-rows → 401; other errors (PG down) go through dbErr → 503.
u, qerr := h.users.GetUserByID(c, uid(c))
if qerr != nil {
if errors.Is(qerr, pgx.ErrNoRows) {
err(c, http.StatusUnauthorized, "unauthorized", "no such user")
return
}
dbErr(c, qerr)
return
}
c.JSON(http.StatusOK, gin.H{"id": u.ID, "username": u.Username, "role": u.Role})
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"bytes"
@@ -10,16 +10,26 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/redis/go-redis/v9"
"booklib/cmd/webui/api"
"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 {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 1, // 测试里 1MB:超限用例只需 2MB body
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func setupAPI(t *testing.T) (*store.Store, *scanner.Scanner, http.Handler, string) {
t.Helper()
url := os.Getenv("DATABASE_URL")
@@ -49,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 := 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)
+131
View File
@@ -0,0 +1,131 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"booklib/internal/store"
)
const maxNoteRunes = 500
func bookmarkJSON(b store.Bookmark) gin.H {
return gin.H{
"id": b.ID, "library_id": b.LibraryID, "path": b.BookPath,
"locator": json.RawMessage(b.Locator), "percent": b.Percent,
"note": b.Note, "created_at": b.CreatedAt.Format(time.RFC3339),
}
}
func (h *H) ListBookmarks(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
rows, e := h.bookmarks.ListBookmarks(c, uid(c), b.LibraryID, b.Path)
if e != nil {
dbErr(c, e)
return
}
out := make([]gin.H, 0, len(rows))
for _, r := range rows {
out = append(out, bookmarkJSON(r))
}
c.JSON(http.StatusOK, out)
}
func (h *H) CreateBookmark(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
var req struct {
Locator json.RawMessage `json:"locator"`
Percent float64 `json:"percent"`
Note string `json:"note"`
}
if e := c.ShouldBindJSON(&req); e != nil {
err(c, http.StatusBadRequest, "bad_request", "json body required")
return
}
if len(req.Locator) == 0 || string(req.Locator) == "null" || !json.Valid(req.Locator) {
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
return
}
if req.Percent < 0 || req.Percent > 1 {
err(c, http.StatusBadRequest, "bad_request", "percent must be in [0,1]")
return
}
if utf8.RuneCountInString(req.Note) > maxNoteRunes {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return
}
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
}
c.JSON(http.StatusCreated, bookmarkJSON(store.Bookmark{
ID: id, LibraryID: b.LibraryID, BookPath: b.Path,
Locator: req.Locator, Percent: req.Percent, Note: req.Note, CreatedAt: time.Now(),
}))
}
func bookmarkID(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
}
func (h *H) PatchBookmark(c *gin.Context) {
id, ok := bookmarkID(c)
if !ok {
return
}
var req struct {
Note string `json:"note"`
}
if e := c.ShouldBindJSON(&req); e != nil {
err(c, http.StatusBadRequest, "bad_request", "json body required")
return
}
if utf8.RuneCountInString(req.Note) > maxNoteRunes {
err(c, http.StatusBadRequest, "bad_request", "note too long (max 500 characters)")
return
}
updated, e := h.bookmarks.UpdateBookmarkNote(c, uid(c), id, req.Note)
if e != nil {
dbErr(c, e)
return
}
if !updated {
err(c, http.StatusNotFound, "not_found", "no such bookmark")
return
}
c.JSON(http.StatusOK, gin.H{"id": id, "note": req.Note})
}
func (h *H) DeleteBookmark(c *gin.Context) {
id, ok := bookmarkID(c)
if !ok {
return
}
deleted, e := h.bookmarks.DeleteBookmark(c, uid(c), id)
if e != nil {
dbErr(c, e)
return
}
if !deleted {
err(c, http.StatusNotFound, "not_found", "no such bookmark")
return
}
c.Status(http.StatusNoContent)
}
@@ -0,0 +1,88 @@
package handlers_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func TestBookmarkCRUDAndOwnership(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
lib, root := newLibrary(t, st, h, tok, booksDir, "bm")
writeCBZ(t, root+"/x.cbz", 5)
scanNow(t, sc, lib)
bs := []map[string]any{}
json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs)
bid := itoa(bs[0]["id"])
w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"})
if w.Code != 201 {
t.Fatalf("create %d %s", w.Code, w.Body)
}
var bm map[string]any
json.Unmarshal(w.Body.Bytes(), &bm)
bmID := itoa(bm["id"])
if bm["note"] != "伏笔" {
t.Fatalf("echo: %s", w.Body)
}
// 第二本书 + 第二条书签:列表按 percent 升序且不跨书泄漏
writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5)
scanNow(t, sc, lib)
bs = nil
json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs)
yid := itoa(bs[0]["id"])
do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2})
arr := []map[string]any{}
json.Unmarshal(do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil).Body.Bytes(), &arr)
if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) {
t.Fatalf("list order/scope: %v", arr)
}
if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatalf("other book leak: %s", w.Body)
}
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"}); w.Code != 200 {
t.Fatalf("patch %d %s", w.Code, w.Body)
}
// 校验:note 超长 / percent 越界 / locator 缺失
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 {
t.Fatalf("long note want 400 got %d", w.Code)
}
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 {
t.Fatalf("percent want 400 got %d", w.Code)
}
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"percent": 0.5}); w.Code != 400 {
t.Fatalf("locator required got %d", w.Code)
}
// 所有权:member carl 看不见/改不了/删不了 alice 的书签
do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"})
lr := map[string]string{}
json.Unmarshal(do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW}).Body.Bytes(), &lr)
bt := lr["token"]
if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatalf("cross-user leak: %s", w.Body)
}
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 {
t.Fatalf("cross-user patch want 404 got %d", w.Code)
}
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 {
t.Fatalf("cross-user delete want 404 got %d", w.Code)
}
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 {
t.Fatalf("delete %d", w.Code)
}
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 {
t.Fatalf("re-delete want 404 got %d", w.Code)
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -17,8 +16,8 @@ import (
"booklib/internal/store"
)
func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
b, e := a.st.GetBook(c, id)
func (h *H) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
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")
@@ -30,42 +29,24 @@ func (a *api) getBookRow(c *gin.Context, id int64) (store.Book, bool) {
return b, true
}
func (a *api) bookFromParam(c *gin.Context) (store.Book, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
func (h *H) bookFromParam(c *gin.Context) (store.Book, bool) {
id, ok := idParam(c)
if !ok {
return store.Book{}, false
}
return a.getBookRow(c, id)
return h.getBookRow(c, id)
}
func (a *api) getLibRow(c *gin.Context, id int64) (store.Library, bool) {
l, e := a.st.GetLibrary(c, id)
if e != nil {
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{
@@ -87,9 +68,9 @@ func bookJSON(b store.Book, percent float64, libraryName string) gin.H {
return j
}
func (a *api) listBooks(c *gin.Context) {
func (h *H) ListBooks(c *gin.Context) {
libID, _ := strconv.ParseInt(c.Query("library"), 10, 64)
views, e := a.st.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
views, e := h.books.ListBooks(c, libID, c.Query("q"), c.Query("prefix"), uid(c))
if e != nil {
dbErr(c, e)
return
@@ -101,17 +82,17 @@ func (a *api) listBooks(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
func (a *api) getBook(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) GetBook(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
p, e := a.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 := a.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
@@ -119,16 +100,16 @@ func (a *api) getBook(c *gin.Context) {
c.JSON(http.StatusOK, bookJSON(b, p.Percent, lib.Name))
}
func (a *api) deleteBook(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) DeleteBook(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
lib, ok := a.getLibRow(c, b.LibraryID)
lib, ok := h.getLib(c, b.LibraryID)
if !ok {
return
}
root, ok := a.libRoot(c, lib)
root, ok := h.libRoot(c, lib)
if !ok {
return
}
@@ -142,9 +123,9 @@ func (a *api) deleteBook(c *gin.Context) {
return
}
key := bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS))
os.RemoveAll(bookfile.CoverDir(a.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(a.cfg.CacheDir, key))
if e := a.st.DeleteBook(c, b.ID); e != nil {
os.RemoveAll(bookfile.CoverDir(h.cfg.CacheDir, key))
os.RemoveAll(bookfile.PagesDir(h.cfg.CacheDir, key))
if e := h.books.DeleteBook(c, b.ID); e != nil {
dbErr(c, e)
return
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"archive/zip"
@@ -20,7 +20,7 @@ func newLibrary(t *testing.T, st *store.Store, h http.Handler, tok, booksDir, na
t.Helper()
root := filepath.Join(booksDir, name)
os.MkdirAll(filepath.Join(root, "series-a"), 0o755)
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name, "root_path": root})
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name})
if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body)
}
@@ -160,15 +160,3 @@ func TestDeleteUnsafePath403(t *testing.T) {
t.Fatalf("row must survive: %v", e)
}
}
func TestAbsBookPathTraversalRejected(t *testing.T) {
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
t.Fatalf("must reject %q", bad)
}
}
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
t.Fatalf("must accept relative path: %q %v", p, e)
}
}
+192
View File
@@ -0,0 +1,192 @@
package handlers
import (
"errors"
"io/fs"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"booklib/internal/bookfile"
"booklib/internal/store"
)
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
func (h *H) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := h.getLib(c, b.LibraryID)
if !ok {
return "", false
}
return h.libRoot(c, lib)
}
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 {
return
}
h.immutable(c)
dir := bookfile.CoverDir(h.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
if entries, e := os.ReadDir(dir); e == nil {
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
if !strings.Contains(en.Name(), ".tmp") {
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
return
}
}
}
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := h.bookRoot(c, b); ok {
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
}
}
}
case mapContentErr(c, e):
return // 404 已回复
}
// 抽取失败(坏包)→ 落到占位 SVG,与原契约一致
} else {
return // 403 已回复
}
} else {
return // 404/503 已回复
}
}
if c.Writer.Written() {
return
}
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
}
func (h *H) ServeFile(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
abs, ok := checkPath(c, root, b)
if !ok {
return
}
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)
}
// 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
}
idx, e := h.med.PageIndex(c, b.ID, b.FileSize, b.ModTS, root, b.Path)
if e != nil {
if !mapContentErr(c, e) {
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
}
return nil, false
}
return idx, true
}
func (h *H) PagesCount(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, ok := h.pageIndex(c, b, root)
if !ok {
return
}
c.JSON(http.StatusOK, gin.H{"count": len(idx), "chapters": h.med.ChaptersOf(idx)})
}
func (h *H) Page(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
n, e := strconv.Atoi(c.Param("n"))
if e != nil || n < 0 {
err(c, http.StatusBadRequest, "bad_request", "bad page number")
return
}
root, ok := h.bookRoot(c, b)
if !ok {
return
}
idx, ok := h.pageIndex(c, b, root)
if !ok {
return
}
if n >= len(idx) {
err(c, http.StatusNotFound, "not_found", "no such page")
return
}
// 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)
}
@@ -1,6 +1,8 @@
package api
package handlers_test
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"net/http"
@@ -103,6 +105,76 @@ func TestPages(t *testing.T) {
}
}
func zipTo(t *testing.T, path string, kv map[string]string) {
t.Helper()
os.MkdirAll(filepath.Dir(path), 0o755)
buf := &bytes.Buffer{}
zw := zip.NewWriter(buf)
for n, v := range kv {
w, err := zw.Create(n)
if err != nil {
t.Fatal(err)
}
w.Write([]byte(v))
}
zw.Close()
os.WriteFile(path, buf.Bytes(), 0o644)
}
func TestPagesChaptersAndNoImageZip(t *testing.T) {
st, sc, h, booksDir := setupAPI(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",
"__MACOSX/第2季/._0001.jpg": "junk",
})
zipTo(t, filepath.Join(root, "videos.zip"), map[string]string{"ep/01.mkv": "x"})
scanNow(t, sc, lib)
bookID := func(q string) string {
w := do(h, "GET", "/api/books?q="+q, tok, nil)
var bs []map[string]any
json.Unmarshal(w.Body.Bytes(), &bs)
if len(bs) != 1 {
t.Fatalf("q=%s books: %s", q, w.Body)
}
return itoa(bs[0]["id"])
}
cbzID := bookID("show")
w := do(h, "GET", "/api/books/"+cbzID+"/pages", tok, nil)
var pg struct {
Count int `json:"count"`
Chapters []struct {
Title string `json:"title"`
Start int `json:"start"`
} `json:"chapters"`
}
json.Unmarshal(w.Body.Bytes(), &pg)
if pg.Count != 2 || len(pg.Chapters) != 2 {
t.Fatalf("pages want 2/2ch got %s", w.Body)
}
if pg.Chapters[0].Title != "第1話" || pg.Chapters[0].Start != 0 || pg.Chapters[1].Start != 1 {
t.Fatalf("chapters: %s", w.Body)
}
ww := do(h, "GET", "/api/books/"+cbzID+"/pages/0", tok, nil)
if ww.Code != 200 || ww.Body.String() != "IMG1a" {
t.Fatalf("page0 must be real image (junk filtered): %d %s", ww.Code, ww.Body)
}
if ww := do(h, "GET", "/api/books/"+cbzID+"/pages/2", tok, nil); ww.Code != 404 {
t.Fatalf("junk-indexed page must be gone: %d", ww.Code)
}
// 无图 zip → state=error,而不是空白 reader
w = do(h, "GET", "/api/books/"+bookID("videos"), tok, nil)
var bk map[string]any
json.Unmarshal(w.Body.Bytes(), &bk)
if bk["state"] != "error" || !strings.Contains(bk["error"].(string), "no images") {
t.Fatalf("empty-image zip want error, got %s", w.Body)
}
}
func TestBrokenCBZ(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
atok := adminToken(t, h)
@@ -1,4 +1,4 @@
package api
package handlers
import (
"errors"
@@ -6,6 +6,7 @@ import (
"log"
"net"
"net/http"
"strconv"
"strings"
"syscall"
@@ -15,22 +16,45 @@ import (
"booklib/internal/auth"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
"booklib/internal/ports"
)
type api struct {
// 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
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, 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
@@ -46,15 +70,15 @@ func dbErr(c *gin.Context, e error) {
err(c, status, code, "db error")
}
func (a *api) authMw() gin.HandlerFunc {
func (h *H) AuthMw() gin.HandlerFunc {
return func(c *gin.Context) {
h := c.GetHeader("Authorization")
tok, ok := strings.CutPrefix(h, "Bearer ")
hdr := c.GetHeader("Authorization")
tok, ok := strings.CutPrefix(hdr, "Bearer ")
if !ok {
err(c, http.StatusUnauthorized, "unauthorized", "missing bearer token")
return
}
cl, perr := auth.Parse(a.cfg.JWTSecret, tok)
cl, perr := auth.Parse(h.cfg.JWTSecret, tok)
if perr != nil {
err(c, http.StatusUnauthorized, "unauthorized", "invalid token")
return
@@ -65,7 +89,7 @@ func (a *api) authMw() gin.HandlerFunc {
}
}
func (a *api) adminOnly() gin.HandlerFunc {
func (h *H) AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetString("role") != "admin" {
err(c, http.StatusForbidden, "forbidden", "admin only")
@@ -1,34 +1,30 @@
package api
package handlers
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/puddle/v2"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/store"
)
func testCfg() *config.Config {
return &config.Config{Addr: ":8080", JWTSecret: []byte("s3cret"), ScanInterval: time.Minute, UploadMaxMB: 200,
TrustedProxies: []string{"172.16.0.0/12"}} // 与 prod 默认一致: 只有 compose 网段内代理才可信
}
func TestHealthz(t *testing.T) {
r := NewRouter(testCfg(), nil, redispkg.New(""), nil)
req := httptest.NewRequest(http.MethodGet, "/api/healthz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("healthz = %d, want 200", w.Code)
func TestAbsBookPathTraversalRejected(t *testing.T) {
root := "/data/books/lib" // 纯路径逻辑,不碰文件系统,无需 DB
for _, bad := range []string{"../../etc/passwd", "a/../../../etc/x", "../sibling"} {
if _, e := absBookPath(root, store.Book{Path: bad}); e == nil {
t.Fatalf("must reject %q", bad)
}
}
if p, e := absBookPath(root, store.Book{Path: "series-a/vol.cbz"}); e != nil || p != filepath.Join(root, "series-a", "vol.cbz") {
t.Fatalf("must accept relative path: %q %v", p, e)
}
}
+197
View File
@@ -0,0 +1,197 @@
package handlers
import (
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"booklib/internal/bookfile"
"booklib/internal/media"
"booklib/internal/ports"
"booklib/internal/store"
)
// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验)
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) || !bookfile.Contains(books, root) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false
}
if e := os.MkdirAll(root, 0o755); e != nil { // 注册库时目录可能尚未落盘,自愈
err(c, http.StatusInternalServerError, "internal", "library root")
return "", false
}
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.libs.ListLibraries(c)
if e != nil {
dbErr(c, e)
return
}
out := make([]gin.H, 0, len(libs))
for _, l := range libs {
out = append(out, gin.H{"id": l.ID, "name": l.Name, "root_path": l.RootPath,
"created_at": l.CreatedAt.Format(time.RFC3339)})
}
c.JSON(http.StatusOK, out)
}
// CreateLibrary: root_path 由服务端生成(BooksDir/SafeName(name)),不接受客户端指定
func (h *H) CreateLibrary(c *gin.Context) {
var req struct {
Name string `json:"name"`
}
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "name required")
return
}
safe := bookfile.SafeName(req.Name)
if safe == "" || safe == ".." {
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.libs.CreateLibrary(c, req.Name, root)
if e != nil {
if ports.IsUniqueViolation(e) {
err(c, http.StatusConflict, "exists", "name taken")
return
}
dbErr(c, e)
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": root})
}
func (h *H) ScanLibrary(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
if _, ok := h.libRoot(c, lib); !ok {
return
}
go h.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (h *H) Upload(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
root, ok := h.libRoot(c, lib)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.cfg.UploadMaxMB<<20)
fh, e := c.FormFile("file")
if e != nil {
var mbe *http.MaxBytesError
if errors.As(e, &mbe) {
err(c, http.StatusRequestEntityTooLarge, "too_large", "file exceeds upload limit of "+strconv.FormatInt(h.cfg.UploadMaxMB, 10)+"MB")
return
}
err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required")
return
}
name := bookfile.SafeName(fh.Filename)
if bookfile.FormatFromExt(name) == "" {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
// 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()
// 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)
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()
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename")
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"bytes"
@@ -14,23 +14,35 @@ import (
func TestLibraryCreateListUpload(t *testing.T) {
_, _, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
root := filepath.Join(booksDir, "lib1") // 必须落在解析过软链的 booksDir 内
os.MkdirAll(root, 0o755)
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics", "root_path": root})
root := filepath.Join(booksDir, "comics") // 服务端自动拼接 BooksDir/<清洗后的库名>
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body)
}
var lib map[string]any
json.Unmarshal(w.Body.Bytes(), &lib)
if lib["root_path"] != root {
t.Fatalf("root_path want %q got %v", root, lib["root_path"])
}
libID := itoa(lib["id"])
w = do(h, "GET", "/api/libraries", tok, nil)
if !strings.Contains(w.Body.String(), `"comics"`) {
t.Fatalf("list: %s", w.Body)
}
// 相对路径 root 必须 400(前缀校验的根)
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "x", "root_path": "relative/path"})
// 恶意库名必须清洗,root 仍在 booksDir 内
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "../../etc/passwd"})
if w.Code != 201 || !strings.Contains(w.Body.String(), filepath.Join(booksDir, "passwd")) {
t.Fatalf("traversal name want sanitized 201 got %d %s", w.Code, w.Body)
}
// "." / ".." 清洗后非法 → 400
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": ".."})
if w.Code != 400 {
t.Fatalf("relative root want 400 got %d", w.Code)
t.Fatalf("'..' want 400 got %d", w.Code)
}
// 重名(同 root)→ 409
w = do(h, "POST", "/api/libraries", tok, map[string]string{"name": "comics"})
if w.Code != 409 {
t.Fatalf("dup want 409 got %d", w.Code)
}
// 上传:白名单 + 防穿越 + 原子落盘
body, mw := uploadBody("my 01.cbz", []byte("zipbytes"))
@@ -68,6 +80,41 @@ func TestLibraryCreateListUpload(t *testing.T) {
}
}
func TestUploadSizeAndFilename(t *testing.T) {
_, _, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": "s2"})
if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body)
}
var lib map[string]any
json.Unmarshal(w.Body.Bytes(), &lib)
libID := itoa(lib["id"])
// 全角冒号等非 ASCII 文件名正常落盘
body, mw := uploadBody("調教開關:第二季.cbz", []byte("zipbytes"))
req := httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+tok)
ww := httptest.NewRecorder()
h.ServeHTTP(ww, req)
if ww.Code != 202 {
t.Fatalf("cjk name upload %d %s", ww.Code, ww.Body)
}
if _, err := os.Stat(filepath.Join(booksDir, "s2", "調教開關:第二季.cbz")); err != nil {
t.Fatal("cjk upload missing:", err)
}
// 超过 UploadMaxMB(测试=1MB)→ 413 too_large,而非误报 "file required"
body, mw = uploadBody("big.cbz", bytes.Repeat([]byte("x"), 2<<20))
req = httptest.NewRequest("POST", "/api/libraries/"+libID+"/upload", body)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+tok)
ww = httptest.NewRecorder()
h.ServeHTTP(ww, req)
if ww.Code != 413 || !strings.Contains(ww.Body.String(), "too_large") {
t.Fatalf("oversize want 413 too_large got %d %s", ww.Code, ww.Body)
}
}
func uploadBody(filename string, content []byte) (*bytes.Buffer, *multipart.Writer) {
buf := &bytes.Buffer{}
mw := multipart.NewWriter(buf)
@@ -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())
}
}
@@ -1,4 +1,4 @@
package api
package handlers
import (
"encoding/json"
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin"
)
func (a *api) putProgress(c *gin.Context) {
b, ok := a.bookFromParam(c)
func (h *H) PutProgress(c *gin.Context) {
b, ok := h.bookFromParam(c)
if !ok {
return
}
@@ -32,15 +32,15 @@ func (a *api) putProgress(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "locator must be valid json")
return
}
if e := a.st.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
if e := h.progress.UpsertProgress(c, uid(c), b.LibraryID, b.Path, req.Locator, req.Percent); e != nil {
dbErr(c, e)
return
}
c.Status(http.StatusNoContent)
}
func (a *api) listProgress(c *gin.Context) {
rows, e := a.st.ListProgress(c, uid(c))
func (h *H) ListProgress(c *gin.Context) {
rows, e := h.progress.ListProgress(c, uid(c))
if e != nil {
dbErr(c, e)
return
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"encoding/json"
@@ -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())
}
}
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"errors"
"net/http"
"os"
"strconv"
"github.com/gin-gonic/gin"
"booklib/internal/ports"
"booklib/internal/upload"
)
// 分片上传:HTTP 层只做参数绑定与错误映射,域逻辑(指纹续传/分片落盘/拼接/清扫)
// 全部在 internal/upload;会话清扫由 scanner ticker 接管(B16),不在请求路径。
// maxChunkBytes 与 upload 包内常量同值,作为 PutPart 的防御性读取上限。
const maxChunkBytes = 32 << 20
func (h *H) UploadInit(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
lib, ok := h.getLib(c, id)
if !ok {
return
}
if _, ok := h.libRoot(c, lib); !ok {
return
}
var req struct {
Name string `json:"name"`
Size int64 `json:"size"`
ChunkSize int64 `json:"chunkSize"`
}
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "name, size required")
return
}
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})
}
func (h *H) UploadStatus(c *gin.Context) {
recv, e := h.up.Status(c, c.Param("uid"))
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusOK, gin.H{"received": recv})
}
func (h *H) UploadPart(c *gin.Context) {
uid := c.Param("uid")
idx, e := strconv.ParseInt(c.Param("index"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad part index")
return
}
// maxSize 防御性上限:分片声明大小由会话 meta 决定,这里再垫一层 32MB 全局上限
e = h.up.PutPart(c, uid, idx, c.Request.Body, maxChunkBytes)
if e != nil {
h.mapUploadErr(c, e)
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (h *H) UploadComplete(c *gin.Context) {
uid := c.Param("uid")
libID, e := h.up.LibraryID(c, uid)
if e != nil {
h.mapUploadErr(c, e)
return
}
lib, e := h.libs.GetLibrary(c, libID)
if e != nil {
dbErr(c, e)
return
}
root, ok := h.libRoot(c, lib)
if !ok {
return
}
rel, e := h.up.Complete(c, uid, root)
if e != nil {
h.mapUploadErr(c, e)
return
}
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
}
err(c, http.StatusInternalServerError, "internal", "upload failed")
}
}
+157
View File
@@ -0,0 +1,157 @@
package handlers_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func initUpload(t *testing.T, h http.Handler, tok, libID, name string, size, chunk int64) (map[string]any, *httptest.ResponseRecorder) {
t.Helper()
w := do(h, "POST", "/api/libraries/"+libID+"/upload/init", tok,
map[string]any{"name": name, "size": size, "chunkSize": chunk})
var m map[string]any
json.Unmarshal(w.Body.Bytes(), &m)
return m, w
}
func putPart(h http.Handler, tok, uid string, idx int, data []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest("PUT", "/api/uploads/"+uid+"/parts/"+fmt.Sprint(idx), bytes.NewReader(data))
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", "application/octet-stream")
ww := httptest.NewRecorder()
h.ServeHTTP(ww, req)
return ww
}
func mkLib(t *testing.T, h http.Handler, tok, name string) string {
t.Helper()
w := do(h, "POST", "/api/libraries", tok, map[string]string{"name": name})
if w.Code != 201 {
t.Fatalf("create lib %d %s", w.Code, w.Body)
}
var m map[string]any
json.Unmarshal(w.Body.Bytes(), &m)
return itoa(m["id"])
}
func TestUploadChunkedHappyPath(t *testing.T) {
_, _, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
libID := mkLib(t, h, tok, "s2")
content := bytes.Repeat([]byte("調教開關第二季!"), 40000) // ~880KB, <1MB 测试上限
size := int64(len(content))
chunk := int64(400000)
m, w := initUpload(t, h, tok, libID, "調教開關:第二季.zip", size, chunk)
if w.Code != 200 || m["uploadId"] == "" {
t.Fatalf("init %d %s", w.Code, w.Body)
}
uid := m["uploadId"].(string)
n := int((size + chunk - 1) / chunk) // 乱序上传
for _, i := range []int{2, 0, 1} {
lo, hi := int64(i)*chunk, int64(i+1)*chunk
if hi > size {
hi = size
}
if ww := putPart(h, tok, uid, i, content[lo:hi]); ww.Code != 202 {
t.Fatalf("part %d: %d %s", i, ww.Code, ww.Body)
}
}
w = do(h, "GET", "/api/uploads/"+uid, tok, nil)
var st struct{ Received []int }
json.Unmarshal(w.Body.Bytes(), &st)
if len(st.Received) != n {
t.Fatalf("status want %d got %v", n, st.Received)
}
w = do(h, "POST", "/api/uploads/"+uid+"/complete", tok, nil)
if w.Code != 202 {
t.Fatalf("complete %d %s", w.Code, w.Body)
}
var res map[string]any
json.Unmarshal(w.Body.Bytes(), &res)
if res["path"] != "調教開關:第二季.zip" {
t.Fatalf("path: %v", res["path"])
}
got, err := os.ReadFile(filepath.Join(booksDir, "s2", "調教開關:第二季.zip"))
if err != nil || !bytes.Equal(got, content) {
t.Fatalf("assembled file wrong: err=%v eq=%v", err, bytes.Equal(got, content))
}
if entries, _ := os.ReadDir(filepath.Join(booksDir, ".uploads")); len(entries) != 0 {
t.Fatalf("session not cleaned: %v", entries)
}
}
func TestUploadChunkedResumeKeepsParts(t *testing.T) {
_, _, h, _ := setupAPI(t)
tok := adminToken(t, h)
libID := mkLib(t, h, tok, "s2")
content := bytes.Repeat([]byte("x"), 900000)
m, _ := initUpload(t, h, tok, libID, "r.cbz", 900000, 400000)
uid := m["uploadId"].(string)
putPart(h, tok, uid, 0, content[:400000])
// 同指纹重复 init → 复用会话,已传分片保留
m2, _ := initUpload(t, h, tok, libID, "r.cbz", 900000, 400000)
if m2["uploadId"] != uid {
t.Fatalf("resume want same uid got %v", m2["uploadId"])
}
w := do(h, "GET", "/api/uploads/"+uid, tok, nil)
if !strings.Contains(w.Body.String(), "[0]") {
t.Fatalf("resumed status: %s", w.Body)
}
}
func TestUploadChunkedErrors(t *testing.T) {
_, _, h, _ := setupAPI(t)
tok := adminToken(t, h)
libID := mkLib(t, h, tok, "s2")
// 总量超 UPLOAD_MAX_MB(测试=1MB)→ 413,消息带限额
_, w := initUpload(t, h, tok, libID, "big.cbz", 2<<20, 400000)
if w.Code != 413 || !strings.Contains(w.Body.String(), "too_large") {
t.Fatalf("oversize want 413 got %d %s", w.Code, w.Body)
}
// 扩展名白名单
_, w = initUpload(t, h, tok, libID, "virus.exe", 100, 10)
if w.Code != 400 || !strings.Contains(w.Body.String(), "bad_format") {
t.Fatalf("bad ext want 400 got %d %s", w.Code, w.Body)
}
// chunkSize 超 32MB
_, w = initUpload(t, h, tok, libID, "ok.cbz", 100, 40<<20)
if w.Code != 400 {
t.Fatalf("huge chunk want 400 got %d %s", w.Code, w.Body)
}
m, _ := initUpload(t, h, tok, libID, "p.cbz", 1000, 400)
uid := m["uploadId"].(string)
// 越界 index
if ww := putPart(h, tok, uid, 9, bytes.Repeat([]byte("y"), 400)); ww.Code != 400 {
t.Fatalf("index oob want 400 got %d %s", ww.Code, ww.Body)
}
// 分片超期望体积
if ww := putPart(h, tok, uid, 0, bytes.Repeat([]byte("y"), 500)); ww.Code != 413 {
t.Fatalf("part too big want 413 got %d %s", ww.Code, ww.Body)
}
// 缺片 complete
putPart(h, tok, uid, 0, bytes.Repeat([]byte("y"), 400))
w = do(h, "POST", "/api/uploads/"+uid+"/complete", tok, nil)
if w.Code != 400 {
t.Fatalf("missing parts want 400 got %d %s", w.Code, w.Body)
}
// 未知 uid → 404
w = do(h, "GET", "/api/uploads/deadbeefdeadbeefdeadbeefdeadbeef", tok, nil)
if w.Code != 404 {
t.Fatalf("unknown uid want 404 got %d", w.Code)
}
// 非法 uid → 400
w = do(h, "GET", "/api/uploads/zzz", tok, nil)
if w.Code != 400 {
t.Fatalf("bad uid want 400 got %d %s", w.Code, w.Body)
}
}
@@ -1,25 +1,20 @@
package api
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 (a *api) listUsers(c *gin.Context) {
users, e := a.st.ListUsers(c)
func (h *H) ListUsers(c *gin.Context) {
users, e := h.users.ListUsers(c)
if e != nil {
dbErr(c, e)
return
@@ -32,7 +27,7 @@ func (a *api) listUsers(c *gin.Context) {
c.JSON(http.StatusOK, out)
}
func (a *api) createUser(c *gin.Context) {
func (h *H) CreateUser(c *gin.Context) {
var req struct{ Username, Password, Role string }
if c.ShouldBindJSON(&req) != nil {
err(c, http.StatusBadRequest, "bad_request", "json body required")
@@ -46,14 +41,14 @@ func (a *api) createUser(c *gin.Context) {
err(c, http.StatusBadRequest, "bad_request", "password too short (min 8)")
return
}
h, e := auth.HashPassword(req.Password)
hp, e := auth.HashPassword(req.Password)
if e != nil {
err(c, http.StatusInternalServerError, "internal", "hash")
return
}
id, e := a.st.CreateUser(c, req.Username, h, req.Role)
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
}
@@ -63,33 +58,25 @@ func (a *api) createUser(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"id": id, "username": req.Username, "role": req.Role})
}
func (a *api) deleteUser(c *gin.Context) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
func (h *H) DeleteUser(c *gin.Context) {
id, ok := idParam(c)
if !ok {
return
}
if id == uid(c) {
err(c, http.StatusBadRequest, "bad_request", "cannot delete yourself")
return
}
target, e := a.st.GetUserByID(c, id)
if e != nil {
// 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, _ := a.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 := a.st.DeleteUser(c, id); e != nil {
dbErr(c, e)
return
}
@@ -1,4 +1,4 @@
package api
package handlers_test
import (
"encoding/json"
@@ -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")
}
@@ -9,13 +9,15 @@ import (
"syscall"
"time"
"booklib/internal/api"
"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()
-226
View File
@@ -1,226 +0,0 @@
package api
import (
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"booklib/internal/bookfile"
"booklib/internal/store"
)
const defaultCover = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="170"><rect width="120" height="170" rx="6" fill="#2a2a33"/><path d="M30 25h60v120H30z" fill="#3a3a45"/><path d="M30 25h60M60 25v120" stroke="#555" stroke-width="2"/></svg>`
func (a *api) bookRoot(c *gin.Context, b store.Book) (string, bool) {
lib, ok := a.getLibRow(c, b.LibraryID)
if !ok {
return "", false
}
return a.libRoot(c, lib)
}
func (a *api) immutable(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
}
func (a *api) serveCover(c *gin.Context) {
b, ok := a.bookFromParam(c)
if !ok {
return
}
a.immutable(c)
dir := bookfile.CoverDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
if entries, e := os.ReadDir(dir); e == nil {
for _, en := range entries { // 跳过写一半的 .tmp 落盘中间态
if !strings.Contains(en.Name(), ".tmp") {
http.ServeFile(c.Writer, c.Request, filepath.Join(dir, en.Name()))
return
}
}
}
if b.Format == "cbz" || b.Format == "epub" { // 自愈:缓存丢了就地抽封面(重启/卷漂移/扫描器还没跑到)
if root, ok := a.bookRoot(c, b); ok {
if f, size, ok := a.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)
}
}
os.Remove(tmp)
}
}
}
}
}
if c.Writer.Written() { // openBook/bookRoot 已写 403/404/500,不再叠加占位图
return
}
c.Data(http.StatusOK, "image/svg+xml", []byte(defaultCover))
}
func (a *api) serveFile(c *gin.Context) {
b, ok := a.bookFromParam(c)
if !ok {
return
}
root, ok := a.bookRoot(c, b)
if !ok {
return
}
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return
}
c.Header("ETag", `"`+bookfile.Hash(b.FileSize, b.ModTS)+`"`)
c.Header("Cache-Control", "private, must-revalidate")
http.ServeFile(c.Writer, c.Request, abs)
}
func (a *api) openBook(c *gin.Context, b store.Book, root string) (*os.File, int64, bool) {
abs, perr := absBookPath(root, b)
if perr != nil {
err(c, http.StatusForbidden, "forbidden", "unsafe path")
return nil, 0, false
}
f, perr := os.Open(abs)
if perr != nil {
err(c, http.StatusNotFound, "not_found", "file missing on disk")
return nil, 0, false
}
st, perr := f.Stat()
if perr != nil {
f.Close()
err(c, http.StatusInternalServerError, "internal", "stat")
return nil, 0, false
}
return f, st.Size(), true
}
func (a *api) pageIndex(c *gin.Context, b store.Book, root string) ([]string, error) {
hash := bookfile.Hash(b.FileSize, b.ModTS)
key := fmt.Sprintf("pagesidx:%d:%s", b.ID, hash)
if v, ok := a.rdb.Get(c, key); ok && v != "" {
return strings.Split(v, "\n"), nil
}
f, size, ok := a.openBook(c, b, root)
if !ok {
return nil, os.ErrNotExist
}
defer f.Close()
idx, e := bookfile.PageIndex(f, size)
if e != nil {
return nil, e
}
if len(idx) > 0 { // 空索引不缓存,否则 warm 命中 "" 会 Split 出幽灵页
a.rdb.Set(c, key, strings.Join(idx, "\n"), 7*24*time.Hour)
}
return idx, nil
}
func (a *api) pagesCount(c *gin.Context) {
b, ok := a.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
root, ok := a.bookRoot(c, b)
if !ok {
return
}
idx, e := a.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return
}
c.JSON(http.StatusOK, gin.H{"count": len(idx)})
}
func (a *api) page(c *gin.Context) {
b, ok := a.bookFromParam(c)
if !ok {
return
}
if b.Format != "cbz" {
err(c, http.StatusBadRequest, "bad_request", "pages only for cbz")
return
}
n, e := strconv.Atoi(c.Param("n"))
if e != nil || n < 0 {
err(c, http.StatusBadRequest, "bad_request", "bad page number")
return
}
root, ok := a.bookRoot(c, b)
if !ok {
return
}
idx, e := a.pageIndex(c, b, root)
if e != nil {
if c.Writer.Written() {
return // openBook 已写 403/404,不再叠加 422
}
err(c, http.StatusUnprocessableEntity, "broken", e.Error())
return
}
if n >= len(idx) {
err(c, http.StatusNotFound, "not_found", "no such page")
return
}
ext := strings.ToLower(filepath.Ext(idx[n]))
dir := bookfile.PagesDir(a.cfg.CacheDir, bookfile.DirKey(b.ID, bookfile.Hash(b.FileSize, b.ModTS)))
dst := filepath.Join(dir, strconv.Itoa(n)+ext)
if _, e := os.Stat(dst); e != nil { // miss → 解压落盘(并发重做同页幂等,唯一 tmp 名 + rename 原子)
f, size, ok := a.openBook(c, b, root)
if !ok {
return
}
defer f.Close()
data, e := bookfile.ReadEntry(f, size, idx[n])
if e != nil {
err(c, http.StatusInternalServerError, "internal", "extract page")
return
}
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")
return
}
}
a.immutable(c)
http.ServeFile(c.Writer, c.Request, dst)
}
-176
View File
@@ -1,176 +0,0 @@
package api
import (
"context"
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"booklib/internal/bookfile"
"booklib/internal/store"
)
// resolveLibRoot: root_path 必须绝对且落在 BooksDir 内(spec §7 前缀校验)
func (a *api) libRoot(c *gin.Context, lib store.Library) (string, bool) {
root := filepath.Clean(lib.RootPath)
books := filepath.Clean(a.cfg.BooksDir)
if !filepath.IsAbs(root) || (root != books && !strings.HasPrefix(root, books+string(os.PathSeparator))) {
err(c, http.StatusForbidden, "forbidden", "library root outside books dir")
return "", false
}
return root, true
}
func (a *api) listLibraries(c *gin.Context) {
libs, e := a.st.ListLibraries(c)
if e != nil {
dbErr(c, e)
return
}
out := make([]gin.H, 0, len(libs))
for _, l := range libs {
out = append(out, gin.H{"id": l.ID, "name": l.Name, "root_path": l.RootPath,
"created_at": l.CreatedAt.Format(time.RFC3339)})
}
c.JSON(http.StatusOK, out)
}
func (a *api) createLibrary(c *gin.Context) {
var req struct {
Name string `json:"name"`
RootPath string `json:"root_path"`
}
if c.ShouldBindJSON(&req) != nil || req.Name == "" || req.RootPath == "" {
err(c, http.StatusBadRequest, "bad_request", "name and root_path required")
return
}
if !filepath.IsAbs(req.RootPath) {
err(c, http.StatusBadRequest, "bad_request", "root_path must be absolute")
return
}
id, e := a.st.CreateLibrary(c, req.Name, filepath.Clean(req.RootPath))
if e != nil {
if isUnique(e) {
err(c, http.StatusConflict, "exists", "root_path taken")
return
}
dbErr(c, e)
return
}
c.JSON(http.StatusCreated, gin.H{"id": id, "name": req.Name, "root_path": filepath.Clean(req.RootPath)})
}
func (a *api) getLibrary(c *gin.Context) (store.Library, bool) {
id, e := strconv.ParseInt(c.Param("id"), 10, 64)
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "bad id")
return store.Library{}, false
}
lib, e := a.st.GetLibrary(c, id)
if e != nil {
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 (a *api) scanLibrary(c *gin.Context) {
lib, ok := a.getLibrary(c)
if !ok {
return
}
if _, ok := a.libRoot(c, lib); !ok {
return
}
go a.sc.ScanLibraryByID(context.WithoutCancel(c), lib.ID)
c.JSON(http.StatusAccepted, gin.H{"accepted": true})
}
func (a *api) upload(c *gin.Context) {
lib, ok := a.getLibrary(c)
if !ok {
return
}
root, ok := a.libRoot(c, lib)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, a.cfg.UploadMaxMB<<20)
fh, e := c.FormFile("file")
if e != nil {
err(c, http.StatusBadRequest, "bad_request", "multipart field 'file' required")
return
}
name := bookfile.SafeName(fh.Filename)
if bookfile.FormatFromExt(name) == "" {
err(c, http.StatusBadRequest, "bad_format", "extension must be cbz/pdf/epub/txt/md")
return
}
dst, e := a.uniquePath(root, name)
if e != nil {
err(c, http.StatusForbidden, "forbidden", e.Error())
return
}
src, e := fh.Open()
if e != nil {
err(c, http.StatusInternalServerError, "internal", "open upload")
return
}
defer src.Close()
tmp := dst + ".upload-" + strconv.FormatInt(time.Now().UnixNano(), 36)
out, e := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if e != nil {
err(c, http.StatusInternalServerError, "internal", "create tmp")
return
}
if _, e := io.Copy(out, src); e != nil {
out.Close()
os.Remove(tmp)
err(c, http.StatusRequestEntityTooLarge, "too_large", "upload failed")
return
}
out.Close()
if e := os.Rename(tmp, dst); e != nil { // 原子落盘,scanner 自动收编
os.Remove(tmp)
err(c, http.StatusInternalServerError, "internal", "rename")
return
}
c.JSON(http.StatusAccepted, gin.H{"accepted": true, "path": strings.TrimPrefix(dst, root+string(os.PathSeparator))})
}
// uniquePath 清洗后的 name 必须仍在 root 内;重名加 " (n)" 后缀
func (a *api) uniquePath(root, name string) (string, error) {
ext := filepath.Ext(name)
base := strings.TrimSuffix(name, ext)
for i := 0; ; i++ {
cand := base + ext
if i > 0 {
cand = base + " (" + strconv.Itoa(i) + ")" + ext
}
p := filepath.Join(root, cand)
if filepath.Clean(p) != filepath.Join(root, filepath.Clean(cand)) ||
!strings.HasPrefix(filepath.Clean(p), root+string(os.PathSeparator)) {
return "", os.ErrInvalid
}
if _, e := os.Stat(p); os.IsNotExist(e) {
return p, nil
} else if e != nil {
return "", e
}
if i > 999 {
return "", os.ErrExist
}
}
}
-50
View File
@@ -1,50 +0,0 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"booklib/internal/config"
"booklib/internal/redispkg"
"booklib/internal/scanner"
"booklib/internal/store"
)
func NewRouter(cfg *config.Config, st *store.Store, rdb *redispkg.R, sc *scanner.Scanner) *gin.Engine {
gin.SetMode(gin.ReleaseMode)
a := &api{cfg: cfg, st: st, rdb: rdb, sc: sc}
r := gin.New()
if e := r.SetTrustedProxies(cfg.TrustedProxies); e != nil {
panic(e)
}
r.Use(gin.Recovery())
g := r.Group("/api")
g.GET("/healthz", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
g.POST("/auth/login", a.login)
p := g.Group("", a.authMw())
p.GET("/auth/me", a.me)
users := p.Group("/users", a.adminOnly())
users.GET("", a.listUsers)
users.POST("", a.createUser)
users.DELETE("/:id", a.deleteUser)
libs := p.Group("/libraries")
libs.GET("", a.listLibraries)
libs.POST("", a.adminOnly(), a.createLibrary)
libs.POST("/:id/scan", a.adminOnly(), a.scanLibrary)
libs.POST("/:id/upload", a.adminOnly(), a.upload)
p.GET("/books", a.listBooks)
p.GET("/books/:id", a.getBook)
p.DELETE("/books/:id", a.adminOnly(), a.deleteBook)
p.GET("/books/:id/cover", a.serveCover)
p.GET("/books/:id/file", a.serveFile)
p.GET("/books/:id/pages", a.pagesCount)
p.GET("/books/:id/pages/:n", a.page)
p.PUT("/books/:id/progress", a.putProgress)
p.GET("/progress", a.listProgress)
return r
}
+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")
}
}
+11
View File
@@ -26,6 +26,14 @@ func isImage(name string) bool {
return false
}
// junkEntry: macOS 打包混入的资源叉垃圾(__MACOSX/ 目录与 ._* AppleDouble),不是页
func junkEntry(name string) bool {
if strings.HasPrefix(strings.ToLower(name), "__macosx/") {
return true
}
return strings.HasPrefix(path.Base(name), "._")
}
func PageIndex(f io.ReaderAt, size int64) ([]string, error) {
zr, err := zip.NewReader(f, size)
if err != nil {
@@ -36,6 +44,9 @@ func PageIndex(f io.ReaderAt, size int64) ([]string, error) {
if unsafeEntry(zf.Name) {
return nil, fmt.Errorf("%w: %s", ErrUnsafeZip, zf.Name)
}
if junkEntry(zf.Name) {
continue
}
if isImage(zf.Name) {
names = append(names, zf.Name)
}
+17
View File
@@ -38,6 +38,23 @@ func TestPageIndexSortAndFilter(t *testing.T) {
}
}
func TestPageIndexSkipsAppleDouble(t *testing.T) {
r := zipOf(t, "第2季/第2話/0001.jpg", "第2季/第1話/._0001.jpg", "__MACOSX/第2季/._0001.jpg", "第2季/第1話/0001.jpg", "._top.jpg")
idx, err := PageIndex(r, int64(r.Len()))
if err != nil {
t.Fatal(err)
}
want := []string{"第2季/第1話/0001.jpg", "第2季/第2話/0001.jpg"}
if len(idx) != len(want) {
t.Fatalf("got %v want %v", idx, want)
}
for i := range want {
if idx[i] != want[i] {
t.Fatalf("got %v want %v", idx, want)
}
}
}
func TestPageIndexRejectsSlip(t *testing.T) {
for _, bad := range []string{"../evil.jpg", "/etc/passwd.jpg", "a\\..\\b.jpg", "pag\ne.jpg", "pag\re.jpg"} {
r := zipOf(t, bad)
+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
}
@@ -19,3 +19,11 @@ CREATE TABLE IF NOT EXISTS reading_progress (
locator JSONB NOT NULL DEFAULT '{}', percent DOUBLE PRECISION NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, library_id, book_path));
CREATE TABLE IF NOT EXISTS bookmarks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id BIGINT NOT NULL, book_path TEXT NOT NULL,
locator JSONB NOT NULL, percent DOUBLE PRECISION NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path);
+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)
}
+70 -68
View File
@@ -2,29 +2,40 @@ package scanner
import (
"context"
"errors"
"fmt"
"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"
)
// Sweeper 是 scanner 每轮顺手调用的清理钩子;upload.U 满足它(B16)。
type Sweeper interface {
Sweep(ctx context.Context) error
}
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) *Scanner {
return &Scanner{st: st, cfg: cfg, rdb: rdb}
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) {
@@ -35,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)
}
}
}
@@ -53,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
}
@@ -84,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 { // 只剩被删的文件
@@ -99,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 {
@@ -136,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
@@ -146,92 +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 {
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 idxErr != nil {
s.st.SetBookState(ctx, id, "error", idxErr.Error())
return
}
s.cover(ctx, id, root, rel, format, ds)
}
func (s *Scanner) update(ctx context.Context, libID, bookID int64, root, rel string, ds diskStat) {
format := bookfile.FormatFromExt(filepath.Base(rel))
pageCount := 0
var idxErr error
if format == "cbz" {
idx, err := s.zipIndex(root, rel)
pageCount = len(idx)
idxErr = err
}
if err := s.st.UpdateBookFile(ctx, bookID, ds.size, ds.modTS, pageCount); err != nil {
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()
}
+35 -267
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,292 +49,45 @@ 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
type Bookmark struct {
ID int64
LibraryID int64
BookPath string
Locator []byte
Percent float64
Note string
CreatedAt time.Time
}
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()
}
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)
}
}
+13
View File
@@ -0,0 +1,13 @@
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
# 上传总大小上限(MB)。前端 >16MB 自动分片(每片 8MB),nginx 体积限制只需盖住单个分片
UPLOAD_MAX_MB=2048
# ---- 部署参数(deploy/prepare.sh 渲染模板 / compose 插值用) ----
WEB_PORT=8080
NGINX_CLIENT_MAX_BODY_SIZE=32m
REDIS_MAXMEMORY=128mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
DELVE_PORT=2345
+7
View File
@@ -0,0 +1,7 @@
.env
nginx/nginx.conf
nginx/conf.d/default.conf
redis/redis.conf
logs/
api/storage/*
!api/storage/.gitkeep
-14
View File
@@ -1,14 +0,0 @@
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
FROM alpine:3.20
RUN adduser -D -H app
COPY --from=build /server /server
RUN mkdir -p /data/cache /data/books && chown app:app /data/cache /data/books
USER app
EXPOSE 8080
ENTRYPOINT ["/server"]
-13
View File
@@ -1,13 +0,0 @@
FROM node:22-alpine AS build
WORKDIR /src
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
COPY --from=build /src/dist /usr/share/nginx/html
# entrypoint 先按 /etc/resolv.conf 注入 resolver(Docker/podman 双运行时),再执行继承的 nginx CMD
ENTRYPOINT ["/entrypoint-resolver.sh"]
View File
+44 -2
View File
@@ -1,9 +1,51 @@
# dev:源码热挂载 + target: dev 镜像 + delve :2345,infra 端口暴露宿主,healthcheck 门控
# 起停用 down(不是 rm),否则 web 的匿名 node_modules 卷会成孤儿
name: booklib
services:
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
ports: ["5433:5432"]
ports: ["5432:5432"]
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
ports: ["6380:6379"]
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports: ["6379:6379"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
api:
image: booklib/api:dev
build: { context: .., dockerfile: backend/Dockerfile.dev, target: dev }
command: sh -c "go mod download && dlv debug ./cmd/webui --headless --listen=0.0.0.0:2345 --api-version=2 --accept-multiclient --continue --log"
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-200}
volumes:
- ../backend:/app
- ./api/storage:/data/books
ports: ["${DELVE_PORT:-2345}:2345"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
web:
image: booklib/web:dev
build: { context: .., dockerfile: frontend/Dockerfile.dev, target: dev }
command: npm run dev -- --host 0.0.0.0
environment: { VITE_PROXY_TARGET: http://api:8080 }
volumes:
- ../frontend:/app
- /app/node_modules
ports: ["5173:5173"]
depends_on: [api]
volumes:
postgres_data:
redis_data:
+44
View File
@@ -0,0 +1,44 @@
# release:编译产物、无源码挂载、infra 端口不出宿主机
name: booklib
services:
web:
image: booklib/web:prod
build: { context: .., dockerfile: frontend/Dockerfile.prod, target: runner }
ports: ["${WEB_PORT:-8080}:80"]
volumes:
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
- ./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on: [api]
api:
image: booklib/api:prod
build: { context: .., dockerfile: backend/Dockerfile.prod, target: runner }
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-200}
volumes:
- ./api/storage:/data/books
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
volumes:
postgres_data:
redis_data:
+7
View File
@@ -3,11 +3,18 @@
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
# 配置以 :ro 挂在 /etc/booklib/(deploy/prepare.sh 渲染产物),先拷入原位再改写。
set -eu
for f in nginx.conf default.conf; do
[ -r "/etc/booklib/$f" ] || { echo "entrypoint-resolver: missing /etc/booklib/$f — 先跑 deploy/prepare.sh" >&2; exit 1; }
done
cp /etc/booklib/nginx.conf /etc/nginx/nginx.conf
cp /etc/booklib/default.conf /etc/nginx/conf.d/default.conf
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
CONF=/etc/nginx/conf.d/default.conf
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
nginx -t
if [ $# -gt 0 ]; then exec "$@"; fi
exec nginx -g 'daemon off;'
@@ -1,7 +1,7 @@
server {
listen 80;
client_max_body_size 200m;
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);镜像 entrypoint 启动时会按
client_max_body_size {{NGINX_CLIENT_MAX_BODY_SIZE}};
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);容器 entrypoint 启动时会按
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
resolver 127.0.0.11 valid=10s;
+24
View File
@@ -0,0 +1,24 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# 渲染各目录下的 *.tpl → 同位置产物(nginx/nginx.conf 等),并创建运行所需目录。
# 约定:每次 up 之前(或改模板/.env 后)必须重跑本脚本。幂等,可反复执行。
set -eu
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-200m}
REDIS_MAXMEMORY=${REDIS_MAXMEMORY:-128mb}
REDIS_MAXMEMORY_POLICY=${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
# 模板与渲染产物同目录:改模板即改在产物旁边,产物文件名 = 模板名去 .tpl
for t in nginx/nginx.conf.tpl nginx/conf.d/default.conf.tpl redis/redis.conf.tpl; do
[ -f "$t" ] || { echo "prepare.sh: missing template $t" >&2; exit 1; }
done
mkdir -p logs/nginx api/storage
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
nginx/nginx.conf.tpl > nginx/nginx.conf
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
nginx/conf.d/default.conf.tpl > nginx/conf.d/default.conf
sed -e "s|{{REDIS_MAXMEMORY}}|${REDIS_MAXMEMORY}|g" \
-e "s|{{REDIS_MAXMEMORY_POLICY}}|${REDIS_MAXMEMORY_POLICY}|g" \
redis/redis.conf.tpl > redis/redis.conf
echo "prepare.sh: rendered nginx($(pwd)/nginx), redis($(pwd)/redis), logs($(pwd)/logs/nginx), storage($(pwd)/api/storage)"
+4
View File
@@ -0,0 +1,4 @@
# booklib redis 配置 — 由 deploy/prepare.sh 从本目录 redis.conf.tpl 渲染,勿直接编辑产物 redis.conf
maxmemory {{REDIS_MAXMEMORY}}
maxmemory-policy {{REDIS_MAXMEMORY_POLICY}}
dir /data
-34
View File
@@ -1,34 +0,0 @@
services:
web:
build: { context: ., dockerfile: deploy/Dockerfile.web }
ports: ["8080:80"]
depends_on: [api]
api:
build: { context: ., dockerfile: deploy/Dockerfile.api }
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ./library:/data/books
- cache:/data/cache
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
# 故意无 volume:redis 里全是可再生数据(spec §6.2)
volumes:
pgdata:
cache:
+87
View File
@@ -0,0 +1,87 @@
# Changelog / 更新日志
All notable non-WebUI user-visible changes should be documented in this file, newest version on top.
本文件记录所有非 WebUI 的重要用户可见变更,最新版本在最上方。
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
## [Unreleased]
### 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話…)给出真实章节。
- 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)。
+113
View File
@@ -0,0 +1,113 @@
# Changelog (WebUI) / 更新日志
All notable WebUI user-visible changes should be documented in this file, newest version on top.
本文件记录所有 WebUI 的重要用户可见变更,最新版本在最上方。
The format loosely follows Keep a Changelog and can be adapted to the team's habits.
本文档参考了 Keep a Changelog 的思路,也可以根据团队习惯调整。
## [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 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
- Bookmarks in all four readers: in the 书签 tab of the left navigation drawer, 加 captures the current position (page / chapter+offset / EPUB CFI) with an optional note; clicking a row jumps straight back to that spot, ✎ edits the note in place, ✕ deletes. Bookmarks are per-user and server-side, so they follow you across devices.
- 四类阅读器新增书签:在左侧导航抽屉的「书签」选项卡内点「加」把当前位置(页码/章节+章内偏移/EPUB CFI)连同可选备注存下;点条目即跳回原位,✎ 行内改备注,✕ 删除。书签按用户存在服务端,换设备同账号即见。
- Folder-structured comics gain a 目录 tab in the CBZ reader's navigation drawer: each archive folder (第N話…) becomes a chapter that jumps to its first page, with 上一章/下一章 buttons, a current-chapter counter, and the active row highlighted and scrolled into view — flat archives show no extra UI.
- 按目录组织的漫画在 CBZ 阅读器导航抽屉内新增「目录」选项卡:压缩包内每个文件夹(第N話…)即一章,点击直达该话首页;工具条配上一章/下一章与当前章计数,目录内高亮当前章并自动居中;平铺无目录的包不显示多余控件。
- Library uploads over 16MB now auto-switch to the resumable chunked protocol (8MB parts): failed parts retry in place up to 3 times and already-sent parts are skipped, so a flaky upload continues instead of starting over; small files keep the original single request.
- 书库上传超过 16MB 自动切换为可续传分片协议(每片 8MB):失败的分片原地重试至多 3 次,已传片自动跳过,中断后继续而不是从头再来;小文件仍走原单次上传。
- Immersive reading for text & comics (微信读书/Mihon-style): tap the middle of the page to show/hide the reader header and control bar; controls now live in a slide-up bottom sheet — a progress slider jumps to any position (text) or page (CBZ), theme swatches (纸/米/夜) are WYSIWYG color dots, plus A−/A+ font size; body text is serif with a readable justified measure; all prefs persist (localStorage).
- 文本与漫画进入沉浸阅读:点正文中央唤出/隐藏顶栏与工具条;控件收进底部滑出抽屉——进度/页码滑条直接跳到全书任意位置或任意页,主题改为所见即所得的色卡(纸/米/夜),保留 A−/A+ 字号;txt/md 正文为衬线、限宽两端对齐;偏好本地持久化。
- CBZ adds reading modes — 连读 (continuous scroll) / 整页 (snap to page top) / 适高 (one page per screen, full width) — cycle button in the bar, choice remembered; tapping the left/right edges turns a page/screen and ←/→/PageUp/PageDown work too.
- CBZ 新增三种阅读模式——连读 / 整页吸附 / 适高(一页一屏满宽),工具条一键循环切换并记忆;点按画面左右缘翻页(屏),并支持 ←/→/PageUp/PageDown 键。
- PDF reader: emoji skip icons replaced with 首页/末页 text buttons, framed page canvas, and an accessible label announcing arrow-key paging.
- PDF 阅读器:以文字按钮(首页/末页)替换 emoji 图标,页面画布加边框投影,并补充可访问的「方向键翻页」说明。
### 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 工具条的「更多」菜单。
- Reader navigation tightened into one left-side drawer opened by a single 导航 toolbar button: 目录 and 书签 are now tabs of a sidebar shared by all four readers, following the reader theme's colors; the button lands on 目录 when a TOC exists, otherwise straight on bookmarks (flat archives, EPUB, PDF show no tab bar); the separate right-side bookmark panel and the per-reader TOC popovers are gone.
- 阅读器导航收紧为统一左侧抽屉,工具条只留一个「导航」按钮:目录与书签成为四个阅读器共用侧边栏内的两个选项卡,配色跟随阅读器主题;有目录时默认落在「目录」,否则直达书签(平铺包、EPUB、PDF 不显示选项卡条);原右侧书签面板与各阅读器自绘目录浮层移除。
- The web UI was rebuilt on a shadcn/Radix design system: semantic light/dark theme tokens with a system/light/dark switcher (remembered), a persistent app shell (desktop sidebar, tablet icon rail, mobile bottom tabs), a shelf driven by URL-state search/sort/group controls with a flat-first grid and menu-based card actions (confirm dialogs replace native popups), and rebuilt login/admin pages. Readers keep their current chrome and every reading interaction this round; their restyle lands next.
- 网页 UI 以 shadcn/Radix 设计系统重做:语义化明暗主题 token + 系统/浅色/深色切换(记忆);常驻应用壳(桌面侧栏、平板图标栏、手机底部标签);书架改为 URL 状态驱动的搜索/排序/分组工具条,默认平铺网格,卡片操作收进菜单(删除改为对话框确认);登录页与 admin 两页同步重做。阅读器保持现状,改版随下一期到达。
- Shelf rebuilt into a content-first library UI: responsive cover grid (auto-fill 3:4 posters) with a pages/size caption, color-coded format badges, in-cover progress bars, staggered entrance animations, skeleton loading, and dedicated empty/error/searching states; 继续阅读 is now a horizontal resume strip (cover + progress + library); login got a clean brand card; toasts animate with icons; missing covers render a serif-initial placeholder instead of an endless pulse.
- 书架重构为「内容优先」的现代书库界面:响应式封面网格(自适应列宽、3:4 海报),封面下方显示页数/大小、格式彩色徽章、封面内嵌进度条、卡片错峰入场动画、骨架屏加载与空/错/搜索无结果专属状态;「继续阅读」改为横向续读卡片条(封面+进度+所属库);登录页升级为主视觉卡片;Toast 带动画与图标;无封面书籍以衬线首字占位,不再无限脉冲。
- .txt chapter splitting no longer recognizes volumes: 「第X卷/部」 lines stay plain body text and volume-only books fall back to pseudo-sections as before; chapter switching moves from the dropdown to a 目录 sidebar overlay that highlights the current chapter, scrolls it into view, and closes on click-outside or after picking a chapter.
- .txt 章节切分不再感知卷:「第X卷/部」行只作为正文文本,只有卷的书和以前一样退回伪章节切分;换章从下拉框改为「目录」侧边栏浮层——当前章高亮并自动居中可见,点击空白处或选定章节即收起。
- 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.
- 阅读器顶栏右上角不再提供 admin「删除」按钮:删除入口仅保留在书架封面卡片的操作菜单中(带确认对话框),避免阅读时误触翻页区旁的破坏性删除。
### 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),上传明明成功却像没生效;现在在上传开始前对文件列表做快照,计数与遍历都用快照。
- Comic continuous-scroll mode is contiguous again on phones: the size observer used to miss the scroll container (it was attached while the reader still showed "loading", so the box didn't exist), leaving page geometry computed from a stale 480px default width while pages rendered at the real (e.g. 390px) width — every page gained a constant dead gap. The observer now attaches when the box actually mounts; additionally, mobile address-bar show/hide no longer rebuilds the scroll geometry in continuous/whole-page modes, which used to wipe measured page heights and re-trigger a burst of image requests.
- 手机端漫画「连读」模式页面不再上下脱节:尺寸监听器此前在阅读器还在「加载中」时就尝试挂载(滚动容器尚不存在),导致监听从未生效,页高按过时的 480px 默认宽计算而实际按手机真实宽度(如 390px)渲染,每页之间出现恒定空白断层;现改为容器真正挂载时再接管。另外,手机地址栏收展在连读/整页模式下不再重建滚动几何——此前会清空全部实测页高并引发一波图片重复请求。
- Dragging the comic scrollbar (or the page slider) is no longer gritty: scroll handling is coalesced to one update per frame instead of per event, prefetching waits until scrolling settles instead of firing mid-drag, height corrections are batched per frame, the virtual list re-estimates unseen pages from the first measured page size (scrollbar geometry stops morphing under your finger), and slider seeks land instantly instead of restarting a smooth-scroll animation per notch.
- 漫画拖动滚动条(及页码滑条)不再发涩:滚动改为每帧合并处理而非逐事件响应,预取等滚动停稳再发(不再拖拽途中抢带宽),量高补偿按帧合并,虚拟列表用首个实测页高重估所有未加载页(滚动条几何不再拖拽中变形),滑条寻址即时落位(不再每格重启一段平滑动画)。
- .txt/.md files encoded in GBK/GB2312/GB18030 or UTF-16 (typical web-novel exports) no longer render as mojibake: the reader strictly tries UTF-8 first and falls back to GB18030, recognizing UTF-16 by BOM.
- 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卷/部」按卷分组(下拉框分组标签)而非章节边界,只有卷没有章的书按卷切分;阅读进度会恢复到上次的章节。
+106
View File
@@ -0,0 +1,106 @@
# Book & Comic Library
Personal book/comic library: Go+Gin backend (scan/upload ingestion, multi-user JWT, reading progress, disk+Redis cache) + Docker Compose deployment. Design: `superpowers/specs/2026-09-04-book-comic-library-design.md`; deployment spec: `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`. Chinese mirror: `README_zh.md`.
## Deploy mode
- release: `deploy/docker-compose.yml` — multi-stage `backend|frontend/Dockerfile.prod` (target runner) compiled artifacts, no source mounts; infra ports stay on the internal network.
- dev: `deploy/docker-compose.dev.yml` — all four services containerized, source mounted as `../backend:/app` and `../frontend:/app` (frontend with an anonymous `node_modules` volume), `target: dev` images; api runs `go mod download && dlv debug ./cmd/webui` (hot restart without rebuild, delve :2345); infra exposed to host PG 5432 / Redis 6379; healthcheck-gated startup.
## Volume Mount
- Shared named volumes: `booklib_postgres_data`, `booklib_redis_data` (identical names in both composes, so dev/release see the same data; only `down -v` clears them).
- Config/log bind mounts: `deploy/nginx/{nginx.conf,conf.d/default.conf}`, `deploy/redis/redis.conf` (`:ro`) and `deploy/logs/nginx` — all rendered by `deploy/prepare.sh` (templates are the `*.tpl` files beside each output); **wrong/missing config in the container = you forgot to rerun it**.
- File storage: `deploy/api/storage` → container `/data/books` (cache writes to `/data/books/cache`).
## Run it (production shape)
```bash
cp deploy/.env.example deploy/.env # set JWT_SECRET, ADMIN_USER, ADMIN_PASSWORD (>= 8 chars; seed skips and logs below 8)
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
```
- web: `http://localhost:8080` (change via `WEB_PORT`); the API goes through the nginx `/api/` prefix reverse proxy to stateless api replicas (`--scale api=N`).
- Drop raw books into `deploy/api/storage/` (mounted at `/data/books`); the scanner ingests periodically (default 60s).
- nginx access/error logs: `deploy/logs/nginx/`.
## Development
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
```
- Frontend: http://localhost:5173 (vite, HMR works directly; `/api` proxied to the in-container api).
- After editing Go code: `docker compose -f deploy/docker-compose.dev.yml restart api` (recompiles the mounted source, no rebuild).
- Breakpoint debugging: delve headless at `localhost:2345` (VSCode launch: `{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345,"substitutePath":[{"from":"${workspaceFolder}/backend","to":"/app"}]}`; continue via dlv commands after hitting a breakpoint).
- Run tests against directly reachable infra: PG `localhost:5432` (lib/lib/lib), Redis `localhost:6379`:
```bash
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6379'
go vet ./... && gofmt -l .
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 + 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)
```bash
# old PG data → new shared volume
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
# the old cache volume is derived data (covers/unzipped pages), just drop it (auto-rebuilt)
docker volume rm book-comic-library_pgdata book-comic-library_cache
```
Book files: move the contents of the old host `./library/` into `deploy/api/storage/`.
## Trusted proxies & rate limiting
- nginx lives inside the compose network, so api's `ClientIP` only trusts `TRUSTED_PROXY_CIDRS` (comma-separated CIDRs, default `172.16.0.0/12`, the compose subnet). Spoofed external `X-Forwarded-For` can't bypass rate-limit buckets; change this env when the deploy network changes.
- Login is limited to 5 attempts/min/IP **counted per attempt, successful logins included** — brute force and normal high-frequency login share the budget.
## Read this before changing the schema
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
Immutable assets (covers/CBZ pages/raw files) are SW cache-first; read content stays available offline; logging out clears the SW cache.
+106
View File
@@ -0,0 +1,106 @@
# Book & Comic Library
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。英文镜像:`README.md`。
## Deploy mode
- release:`deploy/docker-compose.yml` — 多阶段 `backend|frontend/Dockerfile.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../frontend:/app`(web 服务带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/webui`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
## Volume Mount
- 共享 named volumes:`booklib_postgres_data`、`booklib_redis_data`(两个 compose 同名,dev/release 看到同一份数据;仅 `down -v` 清除)。
- 配置/日志绑定挂载:`deploy/nginx/{nginx.conf,conf.d/default.conf}`、`deploy/redis/redis.conf`(`:ro`)与 `deploy/logs/nginx` —— 全部来自 `deploy/prepare.sh`(模板在各产物同目录的 `*.tpl`),**容器里配置不对/缺失 = 忘了重跑它**。
- 文件存储:`deploy/api/storage` → 容器 `/data/books`(缓存写 `/data/books/cache`)。
## 跑起来(生产形态)
```bash
cp deploy/.env.example deploy/.env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
```
- web: `http://localhost:8080`(`WEB_PORT` 可改),API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
- 原始书放进 `deploy/api/storage/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
- nginx access/error 日志:`deploy/logs/nginx/`。
## 开发
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
```
- 前端: http://localhost:5173(vite,HMR 直接生效;`/api` 代理到容器内 api)。
- Go 改码后:`docker compose -f deploy/docker-compose.dev.yml restart api`(重编译挂载源码,无需 rebuild)。
- 断点调试:delve headless 在 `localhost:2345`(VSCode launch:`{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345,"substitutePath":[{"from":"${workspaceFolder}/backend","to":"/app"}]}`;命中断点后用 dlv 命令继续)。
- 直连基础设施跑测试:PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`:
```bash
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6379'
go vet ./... && gofmt -l .
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 + 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`。
## 旧卷迁移(一次性,升级自上一版部署)
```bash
# 老 PG 数据 → 新共享卷
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
# 老 cache 卷是封面/解压派生数据,直接丢弃(自动重建)
docker volume rm book-comic-library_pgdata book-comic-library_cache
```
书库文件:原宿主 `./library/` 的内容移入 `deploy/api/storage/`。
## 可信代理与限流
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
## 改 schema 前必读
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
不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
+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`.
@@ -0,0 +1,677 @@
# Docker 部署规范化改写 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把 booklib 的 Docker 部署重写为团队规范形态:dev/release 双 compose、共享 named volumes、prepare.sh 渲染配置、dev 源码热挂载 + delve、release 编译产物、文件存储挂 `deploy/api/storage`。
**Architecture:** 单 API(`backend/cmd/server`) + SPA(`web`)不变;部署层重写为 `deploy/` 内自包含(compose 项目目录=deploy/,build context=仓库根 `..`)。配置由 `deploy/templates/*.tpl` 经 `prepare.sh` 渲染为 `deploy/nginx/`、`deploy/redis/` 产物后 `:ro` 挂载。
**Tech Stack:** Docker Compose v2、Go 1.26(delve headless)、Node 22(vite)、nginx:1.27-alpine、postgres:16-alpine、redis:7-alpine。
**Spec:** `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`(本计划的验收即 spec §9)
## Global Constraints
- compose 项目名固定 `name: booklib`(两个 compose 文件顶层都有);named volume 一律命名 `postgres_data`、`redis_data`(实际名 `booklib_postgres_data`/`booklib_redis_data`),两文件完全一致。
- dev = `deploy/docker-compose.dev.yml`(`target: dev` 镜像、源码挂载、PG 宿主 5432、Redis 宿主 6379、delve 2345、healthcheck 门控);release = `deploy/docker-compose.yml`(`Dockerfile.*.prod` `target: runner`、无源码挂载、infra 不出宿主端口)。
- 所有 `:ro` 挂载的配置文件都是 `prepare.sh` 渲染产物,模板是唯一编辑入口;渲染产物目录全部 gitignore。
- 文件存储路径统一 `deploy/api/storage` → 容器 `/data/books`,`CACHE_DIR=/data/books/cache`。
- 环境变量键名不变:`JWT_SECRET`、`ADMIN_USER`、`ADMIN_PASSWORD`、`SCAN_INTERVAL_SEC`(`.env` 迁至 `deploy/.env`)。
- 删除清单(最终态不再存在):根 `docker-compose.yml`、根 `.env.example`、`deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`、根 `library/` 约定。
- 不动业务代码,唯一两处适配:`web/vite.config.ts` 代理目标读 env、两个 smoke 脚本的 `.env` 路径。
---
### Task 1: prepare.sh + 配置模板 + .env 迁移
**Files:**
- Create: `deploy/templates/nginx.conf.tpl`、`deploy/templates/default.conf.tpl`、`deploy/templates/redis.conf.tpl`
- Create: `deploy/prepare.sh`(chmod +x)
- Create: `deploy/api/storage/.gitkeep`
- Move: `.env.example` → `deploy/.env.example`;本地 `.env` → `deploy/.env`(untracked,用 `mv`)
- Modify: `.gitignore`、`.dockerignore`、`scripts/smoke.sh:6`、`scripts/smoke-web.sh:6`
**Interfaces:**
- Produces: 渲染产物 `deploy/nginx/nginx.conf`、`deploy/nginx/conf.d/default.conf`、`deploy/redis/redis.conf`;目录 `deploy/logs/nginx/`。变量表:`WEB_PORT`(8080)、`NGINX_CLIENT_MAX_BODY_SIZE`(200m)、`REDIS_MAXMEMORY`(128mb)、`REDIS_MAXMEMORY_POLICY`(allkeys-lru)、`DELVE_PORT`(2345)。
- [ ] **Step 1: 写三个模板**
`deploy/templates/nginx.conf.tpl`(nginx 主配置,日志写挂载目录):
```nginx
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
```
`deploy/templates/default.conf.tpl`(server 块,内容 = 现 `deploy/nginx.conf`,仅 max_body 参数化;此文件后续会被 entrypoint 拷入容器再注入 resolver,所以 resolver 行保留占位值):
```nginx
server {
listen 80;
client_max_body_size {{NGINX_CLIENT_MAX_BODY_SIZE}};
# 地址为占位默认值(127.0.0.11=Docker 内嵌 DNS);容器 entrypoint 启动时会按
# /etc/resolv.conf 的首个 nameserver 重写本行,兼容 podman aardvark-dns。
resolver 127.0.0.11 valid=10s;
# spec §7 风险接受所假设的 CSP:全部同源,blob:/data: 供 SW/reader 用
add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; connect-src 'self' blob: data:; object-src 'none'; frame-src 'self' blob:" always;
location /api/ {
set $api_upstream http://api:8080; # 变量式 → 每次按 DNS 解析,scale 后轮询到新副本(spec §11)
proxy_pass $api_upstream; # 无 URI 部分:保留 /api 前缀转发
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
root /usr/share/nginx/html;
try_files $uri /index.html;
}
}
```
`deploy/templates/redis.conf.tpl`:
```nginx
# booklib redis 配置 — 由 deploy/prepare.sh 从 templates/redis.conf.tpl 渲染,勿直接编辑产物
maxmemory {{REDIS_MAXMEMORY}}
maxmemory-policy {{REDIS_MAXMEMORY_POLICY}}
dir /data
```
- [ ] **Step 2: 写 `deploy/prepare.sh` 并 chmod +x**
```bash
#!/usr/bin/env bash
# 渲染 deploy/templates/*.tpl → deploy/nginx、deploy/redis,并创建运行所需目录。
# 约定:每次 up 之前(或改模板/.env 后)必须重跑本脚本。幂等,可反复执行。
set -eu
cd "$(dirname "$0")"
if [ -f .env ]; then
set -a; . ./.env; set +a
fi
NGINX_CLIENT_MAX_BODY_SIZE=${NGINX_CLIENT_MAX_BODY_SIZE:-200m}
REDIS_MAXMEMORY=${REDIS_MAXMEMORY:-128mb}
REDIS_MAXMEMORY_POLICY=${REDIS_MAXMEMORY_POLICY:-allkeys-lru}
for t in templates/nginx.conf.tpl templates/default.conf.tpl templates/redis.conf.tpl; do
[ -f "$t" ] || { echo "prepare.sh: missing template $t" >&2; exit 1; }
done
mkdir -p nginx/conf.d redis logs/nginx api/storage
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
templates/nginx.conf.tpl > nginx/nginx.conf
sed -e "s|{{NGINX_CLIENT_MAX_BODY_SIZE}}|${NGINX_CLIENT_MAX_BODY_SIZE}|g" \
templates/default.conf.tpl > nginx/conf.d/default.conf
sed -e "s|{{REDIS_MAXMEMORY}}|${REDIS_MAXMEMORY}|g" \
-e "s|{{REDIS_MAXMEMORY_POLICY}}|${REDIS_MAXMEMORY_POLICY}|g" \
templates/redis.conf.tpl > redis/redis.conf
echo "prepare.sh: rendered nginx($(pwd)/nginx), redis($(pwd)/redis), logs($(pwd)/logs/nginx), storage($(pwd)/api/storage)"
```
- [ ] **Step 3: 迁移 .env 与 .env.example**
```bash
git mv .env.example deploy/.env.example
[ -f .env ] && mv .env deploy/.env
```
改写 `deploy/.env.example` 为:
```bash
JWT_SECRET=change-me-openssl-rand-hex-32
ADMIN_USER=admin
ADMIN_PASSWORD=change-me-min-8
SCAN_INTERVAL_SEC=60
# ---- 部署参数(deploy/prepare.sh 渲染模板 / compose 插值用) ----
WEB_PORT=8080
NGINX_CLIENT_MAX_BODY_SIZE=200m
REDIS_MAXMEMORY=128mb
REDIS_MAXMEMORY_POLICY=allkeys-lru
DELVE_PORT=2345
```
- [ ] **Step 4: 更新 .gitignore / .dockerignore / smoke 脚本,并放置 storage 占位**
```bash
mkdir -p deploy/api/storage && touch deploy/api/storage/.gitkeep
```
`.gitignore`:删除 `library/` 行,追加:
```
deploy/nginx/
deploy/redis/redis.conf
deploy/logs/
deploy/api/storage/*
!deploy/api/storage/.gitkeep
```
`.dockerignore`:删除 `library/` 行,追加 `deploy/logs/`、`deploy/nginx/`、`deploy/redis/`、`deploy/api/storage/`。
`scripts/smoke.sh` 和 `scripts/smoke-web.sh` 的第 6 行 `[ -f .env ] && set -a && . ./.env && set +a` 均替换为:
```bash
ENV_FILE=${ENV_FILE:-deploy/.env}
[ -f "$ENV_FILE" ] || ENV_FILE=.env
[ -f "$ENV_FILE" ] && set -a && . "./$ENV_FILE" && set +a
```
`scripts/smoke.sh` 依赖旧的 `./library:/data/books` 绑定(第 28、55 行),随存储路径迁移同步改为:
```bash
mkdir -p deploy/api/storage/smoke-books # 原:mkdir -p library/smoke-books
[ ! -f deploy/api/storage/smoke-books/note.txt ] || die "file survived delete" # 原:library/smoke-books/note.txt
```
- [ ] **Step 5: 验证渲染**
```bash
deploy/prepare.sh && deploy/prepare.sh # 幂等跑两遍
grep -R '{{' deploy/nginx deploy/redis && echo "FAIL: placeholder left" || echo OK
grep -q 'maxmemory 128mb' deploy/redis/redis.conf && echo OK
ls deploy/logs/nginx deploy/api/storage
```
Expected: 两次 `OK`,无 `FAIL`。再验证缺省值路径:临时 `env -i` 不行(脚本 source .env),改用 `ENV_FILE` 无法关——直接确认 `deploy/.env` 不存在时仍渲染默认值:`mv deploy/.env /tmp/e 2>/dev/null; deploy/prepare.sh; grep 'maxmemory 128mb' deploy/redis/redis.conf && mv /tmp/e deploy/.env 2>/dev/null; true`
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(deploy): prepare.sh + config templates, migrate env to deploy/, gitignore rendered outputs"
```
---
### Task 2: 双 Dockerfile(dev/prod)+ entrypoint-resolver 改造
**Files:**
- Create: `deploy/Dockerfile.api.dev`、`deploy/Dockerfile.api.prod`、`deploy/Dockerfile.web.dev`、`deploy/Dockerfile.web.prod`
- Modify: `deploy/entrypoint-resolver.sh`
- Delete: `deploy/Dockerfile.api`、`deploy/Dockerfile.web`、`deploy/nginx.conf`
**Interfaces:**
- Consumes: Task 1 的 `/etc/booklib/{nginx.conf,default.conf}` 挂载约定(Task 3 落地)。
- Produces: 可构建镜像 target:`Dockerfile.api.dev`→`dev`、`Dockerfile.api.prod`→`runner`、`Dockerfile.web.dev`→`dev`、`Dockerfile.web.prod`→`runner`;build context=仓库根。
- [ ] **Step 1: 写 `deploy/Dockerfile.api.dev`**
```dockerfile
FROM golang:1.26 AS base
WORKDIR /app
FROM base AS dev
# 源码由 compose 挂载进 /app;镜像只带工具链 + delve
RUN go install github.com/go-delve/delve/cmd/dlv@latest
EXPOSE 8080 2345
CMD ["sh", "-c", "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"]
```
- [ ] **Step 2: 写 `deploy/Dockerfile.api.prod`**
```dockerfile
FROM golang:1.26-alpine AS build
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN CGO_ENABLED=0 go build -trimpath -o /server ./cmd/server
FROM alpine:3.20 AS runner
RUN adduser -D -H app
COPY --from=build /server /server
# /data/books 由宿主 bind(./api/storage)覆盖;/data 下目录预建并授权,兼容 podman
RUN mkdir -p /data && chown app:app /data
USER app
EXPOSE 8080
ENTRYPOINT ["/server"]
```
- [ ] **Step 3: 写 `deploy/Dockerfile.web.dev`**
```dockerfile
FROM node:22 AS dev
WORKDIR /app
COPY web/package.json web/package-lock.json ./
RUN npm ci
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
- [ ] **Step 4: 写 `deploy/Dockerfile.web.prod`(不再 baked nginx 配置)**
```dockerfile
FROM node:22-alpine AS build
WORKDIR /src
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine AS runner
COPY --chmod=755 deploy/entrypoint-resolver.sh /entrypoint-resolver.sh
COPY --from=build /src/dist /usr/share/nginx/html
# nginx 配置不 bake 进镜像:由 compose 挂到 /etc/booklib/(渲染产物),
# entrypoint 启动时拷入原位、注入运行时 resolver 再 exec nginx
ENTRYPOINT ["/entrypoint-resolver.sh"]
```
- [ ] **Step 5: 改 `deploy/entrypoint-resolver.sh`(先拷贝再 sed,因 `:ro` 挂载不可原地改写)**
```sh
#!/bin/sh
# 双运行时(Docker/podman)DNS 修复:镜像内 baked 的 resolver 地址无法同时成立
# (Docker=127.0.0.11,podman aardvark=网络网关,见容器 /etc/resolv.conf)。
# 启动前取 resolv.conf 首个 nameserver 注入 nginx 配置,保住 spec §11 的
# 变量式 proxy_pass 运行时重解析(valid=10s,scale/重建后秒级感知新 IP)。
# 配置以 :ro 挂在 /etc/booklib/(deploy/prepare.sh 渲染产物),先拷入原位再改写。
set -eu
for f in nginx.conf default.conf; do
[ -r "/etc/booklib/$f" ] || { echo "entrypoint-resolver: missing /etc/booklib/$f — 先跑 deploy/prepare.sh" >&2; exit 1; }
done
cp /etc/booklib/nginx.conf /etc/nginx/nginx.conf
cp /etc/booklib/default.conf /etc/nginx/conf.d/default.conf
RESOLVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null || true)
[ -n "$RESOLVER" ] || RESOLVER=127.0.0.11
CONF=/etc/nginx/conf.d/default.conf
sed -i "s#resolver [0-9a-fA-F:.]* valid=#resolver ${RESOLVER} valid=#" "$CONF"
echo "entrypoint-resolver: resolver=${RESOLVER} injected into ${CONF}"
nginx -t
if [ $# -gt 0 ]; then exec "$@"; fi
exec nginx -g 'daemon off;'
```
- [ ] **Step 6: 删除旧文件并构建四个镜像验证**
```bash
git rm deploy/Dockerfile.api deploy/Dockerfile.web deploy/nginx.conf
docker build -f deploy/Dockerfile.api.dev --target dev -t booklib:api-dev .
docker build -f deploy/Dockerfile.api.prod --target runner -t booklib:api-prod .
docker build -f deploy/Dockerfile.web.dev --target dev -t booklib:web-dev .
docker build -f deploy/Dockerfile.web.prod --target runner -t booklib:web-prod .
docker run --rm --entrypoint sh booklib:api-prod -c 'test -x /server && echo binary-ok'
docker run --rm --entrypoint sh booklib:api-dev -c 'command -v dlv >/dev/null && echo dlv-ok'
docker run --rm --entrypoint sh booklib:web-dev -c 'test -d node_modules/vite && echo deps-ok'
```
Expected: 四个 build 成功;`binary-ok`、`dlv-ok`、`deps-ok`。
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat(deploy): split Dockerfiles into dev/runner targets, entrypoint copies rendered nginx conf before resolver injection"
```
---
### Task 3: release compose(deploy/docker-compose.yml)
**Files:**
- Create: `deploy/docker-compose.yml`
- Delete: `docker-compose.yml`(根)
**Interfaces:**
- Consumes: Task 1 渲染产物路径、Task 2 的镜像 target 与 `/etc/booklib/` 约定、`deploy/.env`。
- Produces: 服务名 `web|api|postgres|redis`(Task 4 dev 文件沿用同名)。
- [ ] **Step 1: 写 `deploy/docker-compose.yml`**
```yaml
# release:编译产物、无源码挂载、infra 端口不出宿主机
name: booklib
services:
web:
build: { context: .., dockerfile: deploy/Dockerfile.web.prod, target: runner }
ports: ["${WEB_PORT:-8080}:80"]
volumes:
- ./nginx/nginx.conf:/etc/booklib/nginx.conf:ro
- ./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on: [api]
api:
build: { context: .., dockerfile: deploy/Dockerfile.api.prod, target: runner }
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ./api/storage:/data/books
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
volumes:
postgres_data:
redis_data:
```
- [ ] **Step 2: 删除根 compose**
```bash
git rm docker-compose.yml
```
- [ ] **Step 3: 未跑 prepare 的失败路径验证(spec §8)**
```bash
mv deploy/nginx /tmp/bk-ng && docker compose -f deploy/docker-compose.yml up -d 2>&1 | grep -i "not found\|error"; mv /tmp/bk-ng deploy/nginx
```
Expected: compose 因绑定挂载源缺失报错,无容器半起(如有残留先 `docker compose -f deploy/docker-compose.yml down`)。
- [ ] **Step 4: 起 release 并跑双冒烟**
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
docker compose -f deploy/docker-compose.yml ps # 全 healthy/running
curl -s localhost:8080/api/healthz
```
Expected: 两套冒烟全绿、`ok`。若宿主 :8080 被 Task 4 前的旧栈占用,先 `docker compose down` 旧项目。
- [ ] **Step 5: 验证卷名与 nginx 日志落宿主**
```bash
docker volume ls | grep booklib_
docker compose -f deploy/docker-compose.yml exec web sh -c 'ls /etc/nginx/conf.d/default.conf >/dev/null && grep -m1 resolver /etc/nginx/conf.d/default.conf'
ls deploy/logs/nginx/ # 有 access.log(冒烟请求后)
```
Expected: `booklib_postgres_data`、`booklib_redis_data`;resolver 行已是容器内实际 nameserver(非 127.0.0.11 亦可——Docker 下就是 127.0.0.11,podman 下为网关 IP);access.log 非空。
- [ ] **Step 6: Commit(数据卷留在盘上给 Task 4 做共享验证)**
```bash
git add -A
git commit -m "feat(deploy): release compose under deploy/ with shared named volumes and rendered config mounts; drop root compose"
```
---
### Task 4: dev compose(四服务全容器化)+ vite 代理适配
**Files:**
- Create: `deploy/docker-compose.dev.yml`(整体重写,替换旧内容)
- Modify: `web/vite.config.ts`(`server.proxy` 一行)
**Interfaces:**
- Consumes: Task 2 dev 镜像(`go`/`dlv`/`npm` 可用)、Task 1 `deploy/api/storage`、`deploy/redis/redis.conf`、Task 3 的卷(同名 → 数据共享)。
- Produces: `VITE_PROXY_TARGET` env 约定;dev 端口头约定:宿主 PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`、delve `:2345`、vite `:5173`。
- [ ] **Step 1: vite 代理目标可配置**
`web/vite.config.ts` 中 `server: { proxy: { "/api": "http://localhost:8080" } },` 替换为:
```ts
server: { proxy: { "/api": process.env.VITE_PROXY_TARGET ?? "http://localhost:8080" } },
```
- [ ] **Step 2: 重写 `deploy/docker-compose.dev.yml`**
```yaml
# dev:源码热挂载 + target: dev 镜像 + delve :2345,infra 端口暴露宿主,healthcheck 门控
# 起停用 down(不是 rm),否则 web 的匿名 node_modules 卷会成孤儿
name: booklib
services:
postgres:
image: postgres:16-alpine
environment: { POSTGRES_USER: lib, POSTGRES_PASSWORD: lib, POSTGRES_DB: lib }
ports: ["5432:5432"]
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U lib"], interval: 2s, timeout: 2s, retries: 30 }
redis:
image: redis:7-alpine
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
ports: ["6379:6379"]
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
- redis_data:/data
api:
build: { context: .., dockerfile: deploy/Dockerfile.api.dev, target: dev }
command: sh -c "go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --continue --log"
environment:
DATABASE_URL: postgres://lib:lib@postgres:5432/lib?sslmode=disable
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
ADMIN_USER: ${ADMIN_USER}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
BOOKS_DIR: /data/books
CACHE_DIR: /data/books/cache
SCAN_INTERVAL_SEC: ${SCAN_INTERVAL_SEC:-60}
volumes:
- ../backend:/app
- ./api/storage:/data/books
ports: ["${DELVE_PORT:-2345}:2345"]
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_started }
web:
build: { context: .., dockerfile: deploy/Dockerfile.web.dev, target: dev }
command: npm run dev -- --host 0.0.0.0
environment: { VITE_PROXY_TARGET: http://api:8080 }
volumes:
- ../web:/app
- /app/node_modules
ports: ["5173:5173"]
depends_on: [api]
volumes:
postgres_data:
redis_data:
```
- [ ] **Step 3: 起 dev 栈并验证四要点**
```bash
docker compose -f deploy/docker-compose.yml down # 先停 release,端口让给 dev
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
curl -sf localhost:5173/api/healthz # vite 代理 → 容器 api:200
curl -sf localhost:5173 | grep -qi '<div id="root">' # vite 页面
nc -z localhost 2345 && echo delve-ok # delve 端口
nc -z localhost 5432 && nc -z localhost 6379 && echo infra-ok
```
Expected: `delve-ok`、`infra-ok`,两个 curl 成功。
- [ ] **Step 4: 验证热重启语义(改码不 rebuild)**
```bash
touch backend/cmd/server/main.go
docker compose -f deploy/docker-compose.dev.yml restart api
sleep 8 && curl -sf localhost:5173/api/healthz && echo hot-reload-ok
```
Expected: `hot-reload-ok`(dlv 重新编译挂载的源码后健康检查恢复)。
- [ ] **Step 5: 验证数据与 release 共享(spec §9.3)**
Task 3 冒烟写入的数据(卷同名共享)应透过 dev 可见——用与 `scripts/smoke.sh` 相同的登录形状(`/api/auth/login`,body `{"username","password"}`)经 5173 代理断言:
```bash
set -a; . deploy/.env; set +a
TOK=$(curl -fsS localhost:5173/api/auth/login -H 'content-type: application/json' \
-d "{\"username\":\"$ADMIN_USER\",\"password\":\"$ADMIN_PASSWORD\"}" | sed -E 's/.*"token":"([^"]+)".*/\1/')
[ -n "$TOK" ] && echo login-ok
curl -fsS localhost:5173/api/libraries -H "authorization: Bearer $TOK" | grep -q '"smoke"' && echo shared-data-ok
```
Expected: `login-ok`、`shared-data-ok`(libraries 含 Task 3 冒烟创建的 `smoke` 库)。
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat(deploy): dev compose runs full stack with source mounts, delve, shared volumes; vite proxy target via env"
```
---
### Task 5: README 重写 + 旧卷迁移文档 + 终验
**Files:**
- Modify: `README.md`(整体重写,见 Step 1)
**Interfaces:**
- Consumes: Task 1–4 全部产物。
- Produces: 无(收尾)。
- [ ] **Step 1: 重写 `README.md`**
````markdown
# Book & Comic Library
个人书库/漫画库:Go+Gin 后端(扫描/上传入库、多用户 JWT、阅读进度、磁盘+Redis 缓存)+ Docker Compose 部署。设计见 `docs/superpowers/specs/2026-09-04-book-comic-library-design.md`;部署规范见 `docs/superpowers/specs/2026-09-07-docker-deploy-spec-design.md`。
## Deploymode
- release:`deploy/docker-compose.yml` — 多阶段 `Dockerfile.{api,web}.prod`(target runner)编译产物,不挂源码;infra 端口只在容器网络。
- dev:`deploy/docker-compose.dev.yml` — 四服务全容器化,源码挂 `../backend:/app`、`../web:/app`(web 带匿名 `node_modules` 卷),`target: dev` 镜像;api 跑 `go mod download && dlv debug ./cmd/server`(热重启不 rebuild,delve :2345);infra 暴露宿主 PG 5432 / Redis 6379;healthcheck 门控。
## Volume Mount
- 共享 named volumes:`booklib_postgres_data`、`booklib_redis_data`(两个 compose 同名,dev/release 看到同一份数据;仅 `down -v` 清除)。
- 配置/日志绑定挂载:`deploy/nginx/{nginx.conf,conf.d/default.conf}`、`deploy/redis/redis.conf`(`:ro`)与 `deploy/logs/nginx` —— 全部来自 `deploy/prepare.sh`,**容器里配置不对/缺失 = 忘了重跑它**。
- 文件存储:`deploy/api/storage` → 容器 `/data/books`(缓存写 `/data/books/cache`)。
## 跑起来(生产形态)
```bash
cp deploy/.env.example deploy/.env # 填 JWT_SECRET、ADMIN_USER、ADMIN_PASSWORD(≥8 位,低于 8 位 seed 会跳过并 log)
deploy/prepare.sh
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
```
- web: `http://localhost:8080`(`WEB_PORT` 可改),API 走 nginx `/api/` 前缀反代到无状态 api 副本(`--scale api=N`)。
- 原始书放进 `deploy/api/storage/`(挂到 `/data/books`),scanner 周期入库(默认 60s)。
- nginx access/error 日志:`deploy/logs/nginx/`。
## 开发
```bash
deploy/prepare.sh
docker compose -f deploy/docker-compose.dev.yml up -d --build
```
- 前端: http://localhost:5173(vite,HMR 直接生效;`/api` 代理到容器内 api)。
- Go 改码后:`docker compose -f deploy/docker-compose.dev.yml restart api`(重编译挂载源码,无需 rebuild)。
- 断点调试:delve headless 在 `localhost:2345`(VSCode launch:`{"type":"go","request":"attach","mode":"remote","host":"localhost","port":2345}`;命中断点后用 dlv 命令继续)。
- 直连基础设施跑测试:PG `localhost:5432`(lib/lib/lib)、Redis `localhost:6379`:
```bash
cd backend
export DATABASE_URL='postgres://lib:lib@localhost:5432/lib?sslmode=disable'
export REDIS_URL='redis://localhost:6379'
go vet ./... && gofmt -l .
go test -p 1 -count=1 ./...
```
`-p 1` 是必须的:集成测试共用同一个 PG 库,各自 `DELETE FROM ...` 清表——并行跑会互相删数据导致随机失败。dev 栈起停用 `down`(不是 `rm`),否则匿名 node_modules 卷成孤儿。无 PG/Redis 时依赖它们的测试自动 skip;Redis 挂掉不影响功能(全链路降级为 miss/放行,见 spec §9)。
前端门槛:`cd web && npm run check`(tsc + vitest + vite build)。
## 旧卷迁移(一次性,升级自上一版部署)
```bash
# 老 PG 数据 → 新共享卷
docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/
# 老 cache 卷是封面/解压派生数据,直接丢弃(自动重建)
docker volume rm book-comic-library_pgdata book-comic-library_cache
```
书库文件:原宿主 `./library/` 的内容移入 `deploy/api/storage/`。
## 可信代理与限流
- nginx 在 compose 网络内,api 的 `ClientIP` 只信 `TRUSTED_PROXY_CIDRS`(逗号分隔 CIDR,默认 `172.16.0.0/12`,即 compose 网段)。外部伪造 `X-Forwarded-For` 换不掉限流桶;换部署网络时改这个 env。
- 登录限流 5 次/分钟/IP **按尝试计数,成功登录也计**——爆破和正常高频登录同账。
## 改 schema 前必读
`db.Migrate` 只执行 `schema.sql` 的 `CREATE TABLE IF NOT EXISTS`——对已存在的库**加列/改列不会生效**。任何列变更之前,必须先引入 `schema_migrations` 版本表 + 有序迁移脚本,否则老部署会静默跑在旧结构上。
## PWA
不可变资源(封面/CBZ 页/原文件)SW cache-first,读过的内容离线可翻;登出会清 SW 缓存。
````
- [ ] **Step 2: release 终验(spec §9.1/9.5)**
```bash
docker compose -f deploy/docker-compose.dev.yml down
docker compose -f deploy/docker-compose.yml up -d --build
bash scripts/smoke.sh && bash scripts/smoke-web.sh
cd backend && go vet ./... && gofmt -l . && go test -p 1 -count=1 ./...
cd ../web && npm run check
```
Expected: 冒烟全绿、后端测试全过、前端 check 过。
- [ ] **Step 3: 规范符合性核对表(spec §9.4,逐条人工打勾)**
| 规范条目 | 核对命令/位置 |
|---|---|
| dev/release 两文件路径 | `deploy/docker-compose{.dev,}.yml` |
| dev 源码挂载 + 匿名 node_modules | dev compose `volumes` |
| `target: dev` / `*.prod target: runner` | compose `build.target` |
| infra 端口:dev 暴露(5432/6379)、release 内网 | 两文件 `ports` |
| delve 2345 | dev compose `ports` |
| healthcheck 门控 | dev/release `depends_on.condition` |
| `{$project}_postgres/redis_data` 同名共享 | `docker volume ls` 一次 |
| 渲染配置 `:ro` + logs 挂载 | 两文件 `volumes` |
| 存储挂 `deploy/{service}/storage` | `./api/storage:/data/books` |
- [ ] **Step 4: Commit**
```bash
git add README.md
git commit -m "docs(readme): rewrite run/dev/migrate instructions for dev/release deploy spec"
```
@@ -0,0 +1,212 @@
# 书签系统(备注+跳转)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 全格式阅读书签:捕获当前 locator+percent、可备注、列表一键跳转、行内改删。
**Architecture:** 新 `bookmarks` 表按 `(user_id, library_id, book_path)` 定位(与 progress 同款);REST 四端点 owner-scoped(他人 404);前端 `createProgressSaver` 顺带记录当前值供 `capture()`,共享组件 `Bookmarks.tsx` 在四个 reader 里以 `{btn, panel}` 两段挂载,seek 复用各 reader 现成的定位函数。
**Tech Stack:** Go 1.26 + gin + pgx(backend 容器内跑测试);React + TS + react-query + Tailwind(rd-* 类)。
**Spec:** `docs/superpowers/specs/2026-09-08-bookmarks-design.md`
**Global constraints:**
- 测试执行:`docker exec -w /app booklib-api-1 go test -p 1 -count=1 ./...`(需 DATABASE_URL,容器已配);前端 `docker exec -w /app booklib-web-1 npm run -s check`
- 用户可见变更必须同变更里写 changelog:后端→`docs/CHANGELOG.md`,前端→`docs/CHANGELOG_web.md`,英中相邻两行,不同条目空行隔,新版在上
- locator 形状不变:cbz/pdf `{page:number}`(0 基),txt/md `{ch:number,scrollFraction:number}`,epub `{cfi:string}`
- note 长度按 rune 计 ≤500
---
### Task 1: 后端垂直切片(schema+store+handler+router+测试)
**Files:**
- Modify: `backend/internal/db/schema.sql`(追加表)
- Modify: `backend/internal/store/store.go`(progress 区之后加 Bookmark 方法)
- Create: `backend/cmd/webui/handlers/bookmarks.go`
- Modify: `backend/cmd/webui/api/router.go`(progress 路由旁)
- Test: `backend/cmd/webui/handlers/bookmarks_test.go`
**Interfaces:**
- Produces: `store.Bookmark{ID,LibraryID,BookPath int64/string...,Locator []byte,Percent float64,Note string,CreatedAt time.Time}`;`InsertBookmark(ctx,userID,libID int64,bookPath string,locator []byte,percent float64,note string)(int64,error)`;`ListBookmarks(ctx,userID,libID int64,bookPath string)([]Bookmark,error)`;`UpdateBookmarkNote(ctx,userID,id int64,note string)(bool,error)`;`DeleteBookmark(ctx,userID,id int64)(bool,error)`;HTTP 四端点见下。
- [ ] **Step 1: 写失败测试** `bookmarks_test.go`(复用 setupAPI/adminToken/do/writeCBZ/scanNow/newLibrary/itoa 既有 helper;member 登录用 testPW)
```go
package handlers_test
import (
"encoding/json"
"fmt"
"strings"
"testing"
)
func TestBookmarkCRUDAndOwnership(t *testing.T) {
st, sc, h, booksDir := setupAPI(t)
tok := adminToken(t, h)
lib, root := newLibrary(t, st, h, tok, booksDir, "bm")
writeCBZ(t, root+"/x.cbz", 5)
scanNow(t, sc, lib)
bs := []map[string]any{}
json.Unmarshal(do(h, "GET", "/api/books?q=x", tok, nil).Body.Bytes(), &bs)
bid := itoa(bs[0]["id"])
w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 3}, "percent": 0.6, "note": "伏笔"})
if w.Code != 201 {
t.Fatalf("create %d %s", w.Code, w.Body)
}
var bm map[string]any
json.Unmarshal(w.Body.Bytes(), &bm)
bmID := itoa(bm["id"])
if bm["note"] != "伏笔" {
t.Fatalf("echo: %s", w.Body)
}
// 第二本书 + 第二条书签,列表按 percent 升序且不跨书
writeCBZ(t, fmt.Sprintf("%s/y.cbz", root), 5)
scanNow(t, sc, lib)
bs = nil
json.Unmarshal(do(h, "GET", "/api/books?q=y", tok, nil).Body.Bytes(), &bs)
yid := itoa(bs[0]["id"])
do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.2})
lst := struct{ N int; First float64 }{}
w = do(h, "GET", "/api/books/"+bid+"/bookmarks", tok, nil)
var arr []map[string]any
json.Unmarshal(w.Body.Bytes(), &arr)
if len(arr) != 2 || arr[0]["percent"].(float64) > arr[1]["percent"].(float64) {
t.Fatalf("list order/scope: %s", w.Body)
}
_ = lst
if w := do(h, "GET", "/api/books/"+yid+"/bookmarks", tok, nil); strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatalf("other book leak: %s", w.Body)
}
// 改备注
w = do(h, "PATCH", "/api/bookmarks/"+bmID, tok, map[string]string{"note": "改了"})
if w.Code != 200 {
t.Fatalf("patch %d %s", w.Code, w.Body)
}
// 校验:note 超长 / percent 越界 / locator 缺失
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 0.5, "note": strings.Repeat("字", 501)}); w.Code != 400 {
t.Fatalf("long note want 400 got %d", w.Code)
}
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"locator": map[string]int{"page": 1}, "percent": 1.5}); w.Code != 400 {
t.Fatalf("percent want 400 got %d", w.Code)
}
if w := do(h, "POST", "/api/books/"+bid+"/bookmarks", tok,
map[string]any{"percent": 0.5}); w.Code != 400 {
t.Fatalf("locator required got %d", w.Code)
}
// 所有权:bob 看不见也改不了 alice 的书签
do(h, "POST", "/api/users", tok, map[string]string{"username": "carl", "password": testPW, "role": "member"})
w = do(h, "POST", "/api/auth/login", "", map[string]string{"username": "carl", "password": testPW})
var lr map[string]string
json.Unmarshal(w.Body.Bytes(), &lr)
bt := lr["token"]
if w := do(h, "GET", "/api/books/"+bid+"/bookmarks", bt, nil); strings.TrimSpace(w.Body.String()) != "[]" {
t.Fatalf("cross-user leak: %s", w.Body)
}
if w := do(h, "PATCH", "/api/bookmarks/"+bmID, bt, map[string]string{"note": "抢"}); w.Code != 404 {
t.Fatalf("cross-user patch want 404 got %d", w.Code)
}
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, bt, nil); w.Code != 404 {
t.Fatalf("cross-user delete want 404 got %d", w.Code)
}
// 本人删除 → 204,再删 404
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 204 {
t.Fatalf("delete %d", w.Code)
}
if w := do(h, "DELETE", "/api/bookmarks/"+bmID, tok, nil); w.Code != 404 {
t.Fatalf("re-delete want 404 got %d", w.Code)
}
}
```
- [ ] **Step 2: 跑测试确认失败(404/编译错)**
Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1`
Expected: FAIL(路由不存在)
- [ ] **Step 3: schema.sql 追加**(文件末尾)
```sql
CREATE TABLE IF NOT EXISTS bookmarks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id BIGINT NOT NULL,
book_path TEXT NOT NULL,
locator JSONB NOT NULL,
percent DOUBLE PRECISION NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx ON bookmarks (user_id, library_id, book_path);
```
- [ ] **Step 4: store.go 四个方法 + handlers/bookmarks.go + router 四条**(签名见 Interfaces;JSON 键:`id,library_id,path,locator,percent,note,created_at`(RFC3339);PATCH 返回 200 `{id,note}`;book 解析用 `h.bookFromParam`;`uid(c)` 取用户;校验:percent∈[0,1]、`json.Valid(locator)` 且非空、`utf8.RuneCountInString(note)<=500`)
- [ ] **Step 5: 跑测试转绿 + 全包回归 vet**
Run: `docker exec -w /app booklib-api-1 go test ./cmd/webui/handlers/ -run TestBookmark -count=1 && docker exec -w /app booklib-api-1 go vet ./...`
- [ ] **Step 6: Commit** `feat(bookmarks): api+store — per-user CRUD, owner-scoped 404, percent-ordered list`
---
### Task 2: 前端管道(saver.capture + client API + types)
**Files:**
- Modify: `frontend/src/lib/progress.ts`、`frontend/src/lib/useProgress.ts`、`frontend/src/api/client.ts`、`frontend/src/api/types.ts`、`frontend/src/pages/Reader.tsx`
**Interfaces:**
- Consumes: Task 1 的 HTTP 契约
- Produces: `ProgressSaver.at(): {loc: Record<string,unknown>, pct: number} | null`;`useProgressSaver(bookId, seed?)` 返回 `ProgressSaver & { capture(): { locator: Record<string, unknown>; percent: number } }`;`ReaderProps.initialPercent?: number`;`api.listBookmarks/createBookmark/patchBookmark/deleteBookmark`;`types.Bookmark`
- [ ] **Step 1:** progress.ts 的 `createProgressSaver` 里加 `let cur`,report 时赋值,返回对象加 `at: () => cur`
- [ ] **Step 2:** useProgress.ts:`ReaderProps` 加 `initialPercent?: number`;`useProgressSaver(bookId, seed?)` 包一层 `capture = () => saver.at() ?? { locator: seed?.locator ?? {}, percent: seed?.percent ?? 0 }`(at 优先,未动过页时回退到打开时的进度)
- [ ] **Step 3:** client.ts 四方法(路径/体按 Task 1 契约);types.ts:
```ts
export interface Bookmark {
id: number;
locator: Record<string, unknown>;
percent: number;
note: string;
created_at: string;
}
```
- [ ] **Step 4:** Reader.tsx 渲染 `<R>` 处传 `initialPercent={row?.percent}`
- [ ] **Step 5:** `docker exec -w /app booklib-web-1 npm run -s check` 绿
- [ ] **Step 6:** Commit `feat(bookmarks): client plumbing — saver capture + bookmark api`
---
### Task 3: 共享组件 + 四 reader 接线
**Files:**
- Create: `frontend/src/components/Bookmarks.tsx`
- Modify: `frontend/src/readers/CbzReader.tsx`(工具条+根,复用 toc 侧栏样式)、`TextReader.tsx`(TxtView+MdView 的 ReaderSheet children)、`PdfReader.tsx`(底部按钮排)、`EpubReader.tsx`(底部按钮排,根 div 加 relative)
**Interfaces:**
- Consumes: Task 2 的 `capture/api/types`
- Produces: `useBookmarks({ book, capture, seek }): { btn: ReactNode; panel: ReactNode }`(hook 命名过 react-rules)— btn 塞工具条,panel 塞 relative 根容器末尾;内部含面板开合/表单状态与查询
- [ ] **Step 1: 写 Bookmarks.tsx**(要点:`useQuery(["bookmarks",book.id])`;添加=btn 点开小表单(input+确定/取消,空 note 合法)→ POST→invalidate;panel=目录同款(fixed 遮罩 + absolute 左侧 nav,`aria-label="书签"`):行按钮 `{Math.round(percent*100)}% {note||无备注} {日期}`,点击 `seek(locator)`+收起;✎ 行内编辑 note→PATCH;✕→DELETE→invalidate;toast 报错)
- [ ] **Step 2: CbzReader**:`const bm = Bookmarks({book, capture: saver.capture, seek:(l)=>jump(Number(l.page)||0)})`(capture 需 useProgressSaver 已带 seed:`useProgressSaver(book.id, {locator: initialLocator, percent: initialPercent})`);btn 进底部按钮行,panel 进根 `</div>` 前
- [ ] **Step 3: TextReader 两个 View**:`seek` 用既有 `goChapter(Number(l.ch))`+设 `scrollFraction`(照抄 initialLocator 恢复段逻辑,抽成函数复用);ReaderSheet children 放 btn,根放 panel;MdView 同(仅 scrollFraction seek)
- [ ] **Step 4: PdfReader**:`seek:(l)=>{const p=Number(l.page); if(p>=0&&p<num) setPage(p)}`;btn 进底部 `<button>` 排;根容器加 `relative`,panel 进末尾
- [ ] **Step 5: EpubReader**:`seek:(l)=>rendRef.current?.display(String(l.cfi))`(与初始恢复同款 then/catch 容错,坏 cfi 提示 toast);btn 进上一页/下一页排;根 grid 容器 relative
- [ ] **Step 6:** `npm run -s check` + `npm run -s build` 绿;`vitest`(若存在)不红
- [ ] **Step 7:** Commit `feat(bookmarks): shared Bookmarks panel + wired into all four readers`
---
### Task 4: 收尾(回归 + 真实点验 + changelog)
- [ ] **Step 1:** 全量:`go vet ./...` + `go test -p 1 -count=1 ./...`(容器内);`npm run -s check`
- [ ] **Step 2: 环境自愈:** 测试清库后 `DELETE FROM users;...` → 重启 api seed admin → 重建 tv 库 → 浏览器手动点验:cbz 加书签(带备注)→ 翻页 → 打开书签列表点回跳;txt 跨章书签;pdf 页码书签
- [ ] **Step 3: changelog:** `docs/CHANGELOG.md` Added(API 书签四端点一句 + 中文);`docs/CHANGELOG_web.md` Added(阅读器书签面板 英/中)
- [ ] **Step 4:** Commit `chore(bookmarks): changelog + e2e verification`
@@ -0,0 +1,594 @@
# CBZ 章节化连续阅读(锁章 + 预读)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 漫画一章内所有图视为连续整章内容,默认滚动锁在章末(只能按钮切章),可开连读跨章,并预渲染其后 N 章(0–3,默认 2)使切章零白屏;移除 连读/整页/适高 三档模式。
**Architecture:** 纯前端改动。`lib/virt.ts` 新增两个纯函数(章节窗口计算)供单测全覆盖;`CbzReader.tsx` 重构为「章节窗口 + 屏翻页 + 隐藏预读层」,`PageHeights` 虚拟列表模型不动。无后端/API 改动。
**Tech Stack:** React 18 + TypeScript + Tailwind(rd-* 类)、vitest、vite。
Spec: `docs/superpowers/specs/2026-09-08-cbz-chapter-continuous-reading-design.md`
## Global Constraints
- 分支 `feat/ui-redesign`(当前已在此分支,勿切 master 提交)。
- 测试/构建在容器内跑:`docker exec -w /app booklib_web_1 npm run -s check`(= tsc --noEmit + vitest + vite build);单文件测试 `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`。
- localStorage 键:`cbz-continuous`("1"/"0",默认关)、`cbz-prefetch`(0–3 整数,非法回退 2);旧键 `cbz-mode` 废弃不再读写。
- 用户可见变更必须记 `docs/CHANGELOG_web.md`(英中各一行、相邻行、不同条目间空行),不重复进 `docs/CHANGELOG.md`。
- 全书页号语义不变:进度/书签 locator `{page}`、滑条、页码 HUD 均为全书页,不改后端。
- 容器宽上限 `MAX_W = 720` 保留;扁平包(`chapters` 为 null 或长度 < 2)= 整本一章。
---
### Task 1: `lib/virt.ts` 章节窗口纯函数(TDD)
**Files:**
- Modify: `frontend/src/lib/virt.ts`(文件末尾追加)
- Test: `frontend/test/virt.test.ts`(文件末尾追加)
**Interfaces:**
- Consumes: 无(新代码)。
- Produces:
- `chapterIndexAt(chs: { start: number }[] | null, page: number): number` — 页所在章下标;无章/单章恒 0;page 小于首章 start 归 0。
- `chapterWin(chs: { start: number }[] | null, count: number, ci: number, continuous: boolean, prefetch: number): ChapterWindow`,`interface ChapterWindow { start: number; end: number; mountEnd: number }`(start 含、end 不含;连读/扁平 → 全书;锁章 → 本章窗口,mountEnd 含 prefetch 章)。Task 2 按此签名消费。
- [ ] **Step 1: 写失败测试**(追加到 `frontend/test/virt.test.ts` 末尾)
```ts
import { chapterIndexAt, chapterWin } from "../src/lib/virt"; // 与文件顶部 import 合并
const CH4 = [{ start: 0 }, { start: 10 }, { start: 20 }, { start: 30 }];
it("chapterIndexAt:无章/单章恒 0;页号归章;首章 start 之前的散页归 0", () => {
expect(chapterIndexAt(null, 7)).toBe(0);
expect(chapterIndexAt([{ start: 0 }], 7)).toBe(0);
expect(chapterIndexAt(CH4, 0)).toBe(0);
expect(chapterIndexAt(CH4, 9)).toBe(0);
expect(chapterIndexAt(CH4, 10)).toBe(1);
expect(chapterIndexAt(CH4, 999)).toBe(3);
expect(chapterIndexAt([{ start: 5 }, { start: 10 }], 3)).toBe(0); // 根散页
});
it("chapterWin:扁平/连读 = 全书窗口", () => {
expect(chapterWin(null, 40, 0, false, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, 2, true, 2)).toEqual({ start: 0, end: 40, mountEnd: 40 });
});
it("chapterWin:锁章窗口 = 本章;mountEnd 按预读章数延伸、夹到 count", () => {
expect(chapterWin(CH4, 40, 1, false, 0)).toEqual({ start: 10, end: 20, mountEnd: 20 });
expect(chapterWin(CH4, 40, 1, false, 1)).toEqual({ start: 10, end: 20, mountEnd: 30 });
expect(chapterWin(CH4, 40, 1, false, 2)).toEqual({ start: 10, end: 20, mountEnd: 40 });
expect(chapterWin(CH4, 40, 1, false, 3)).toEqual({ start: 10, end: 20, mountEnd: 40 }); // 越界夹住
expect(chapterWin(CH4, 40, 3, false, 2)).toEqual({ start: 30, end: 40, mountEnd: 40 }); // 末章
});
it("chapterWin:ci 越界夹边;首章窗口含首章 start 之前的散页;prefetch 负数按 0", () => {
expect(chapterWin(CH4, 40, 9, false, 1)).toEqual({ start: 30, end: 40, mountEnd: 40 });
expect(chapterWin(CH4, 40, -2, false, 1)).toEqual({ start: 0, end: 10, mountEnd: 20 });
expect(chapterWin([{ start: 5 }, { start: 10 }], 20, 0, false, 0)).toEqual({ start: 0, end: 10, mountEnd: 10 });
expect(chapterWin(CH4, 40, 1, false, -1)).toEqual({ start: 10, end: 20, mountEnd: 20 });
});
```
- [ ] **Step 2: 跑测试确认失败**
Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`
Expected: FAIL —「chapterIndexAt is not exported」/ import 解析错误。
- [ ] **Step 3: 实现**(追加到 `frontend/src/lib/virt.ts` 末尾)
```ts
/** 页号 → 所在章下标;无章(扁平包或单组)恒 0;首章 start 之前的散页归首章。 */
export function chapterIndexAt(chs: { start: number }[] | null, page: number): number {
if (!chs || chs.length < 2) return 0;
return chs.reduce((acc, c, i) => (c.start <= page ? i : acc), 0);
}
export interface ChapterWindow {
start: number; // 窗口首页(含)——滚动/挂载下界
end: number; // 窗口末页(不含)——锁章时的滚动边界
mountEnd: number; // 挂载上界(含预读章),恒 ≤ count 且 ≥ end
}
/** 阅读窗口:连读或扁平包 = 全书;锁章 = 本章滚动、本章 + prefetch 章挂载。 */
export function chapterWin(
chs: { start: number }[] | null,
count: number,
ci: number,
continuous: boolean,
prefetch: number,
): ChapterWindow {
if (!chs || chs.length < 2 || continuous) return { start: 0, end: count, mountEnd: count };
const c = Math.max(0, Math.min(ci, chs.length - 1));
const end = chs[c + 1]?.start ?? count;
return {
start: c === 0 ? 0 : chs[c].start,
end,
mountEnd: chs[c + 1 + Math.max(0, prefetch)]?.start ?? count,
};
}
```
- [ ] **Step 4: 跑测试确认通过(含既有 virt 用例回归)**
Run: `docker exec -w /app booklib_web_1 npx vitest run test/virt.test.ts`
Expected: 全部 PASS(既有 4 条 + 新增 4 条)。
- [ ] **Step 5: Commit**
```bash
git add frontend/src/lib/virt.ts frontend/test/virt.test.ts
git commit -m "feat(cbz): pure chapter-window helpers (chapterIndexAt/chapterWin) with table tests"
```
---
### Task 2: CbzReader 重构为章节窗口模型
**Files:**
- Modify: `frontend/src/readers/CbzReader.tsx`(整文件替换,代码见 Step 1)
- Modify: `docs/CHANGELOG_web.md`(Step 3)
- Test: 无新增组件测试(既有约定:reader UI 走 `npm run check` + 真实浏览器点验)
**Interfaces:**
- Consumes: Task 1 的 `chapterIndexAt` / `chapterWin`;既有 `PageHeights`、`fetchObjectUrl`、`useProgressSaver`、`useBookmarks`、`api.pageCount`(`{count, chapters: {title,start}[] | null}`)、`chrome`(ReaderProps)。
- Produces: 重写后的默认导出 `CbzReader(props: ReaderProps)`;删除 `PageMode/MODES/MODE_LABEL/useStoredMode/fitH` 等全部旧模式符号(无外部引用者)。
- [ ] **Step 1: 用以下完整内容替换 `frontend/src/readers/CbzReader.tsx`**
```tsx
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui";
import { useBookmarks } from "../components/Bookmarks";
import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
const MAX_W = 720;
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
function useStoredBool(key: string, def: boolean) {
const [v, setV] = useState(() => {
const s = localStorage.getItem(key);
return s === null ? def : s === "1";
});
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
return [v, setV] as const;
}
function useStoredPrefetch() {
const [n, setN] = useState(() => {
const v = Number(localStorage.getItem("cbz-prefetch"));
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
});
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
return [n, setN] as const;
}
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
const [src, setSrc] = useState("");
const [failed, setFailed] = useState(false);
const [nonce, setNonce] = useState(0);
useEffect(() => {
let dead = false;
setSrc("");
setFailed(false);
fetchObjectUrl(url)
.then((s) => !dead && setSrc(s))
.catch(() => !dead && setFailed(true));
return () => {
dead = true;
};
}, [url, nonce]);
if (failed)
return (
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
本页加载失败
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
重试
</button>
</div>
);
if (!src)
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return (
<img
src={src}
alt=""
style={{ width }}
className="block"
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth > 0) onLoaded((el.naturalHeight / el.naturalWidth) * width);
}}
/>
);
}
export default function CbzReader({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
const countQ = useQuery({
queryKey: ["pages", book.id],
queryFn: () => api.pageCount(book.pages_url ?? ""),
enabled: !!book.pages_url,
retry: 0,
});
const count = countQ.data?.count ?? 0;
const chapters = countQ.data?.chapters ?? null; // 包内目录结构(第N話/上中下卷);null/单组=扁平整本一章
const [toc, setToc] = useState(false);
const activeRow = useRef<HTMLButtonElement>(null);
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
const bm = useBookmarks({ book, capture: saver.capture, seek: (l) => goPage(Number(l.page) || 0) });
const boxRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(480);
const [vh, setVh] = useState(600);
const [top, setTop] = useState(0);
const [scrub, setScrub] = useState<number | null>(null); // 页滑条拖拽中的临时值
const [, bump] = useState(0);
const restored = useRef(false);
const scrollRaf = useRef(0);
const idle = useRef<undefined | ReturnType<typeof setTimeout>>(undefined);
const curRef = useRef(0);
const shift = useRef(0); // 量高补偿按帧合并,避免一次加载多页各改一次 scrollTop
const shiftRaf = useRef(0);
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
const w = useMemo(
() => chapterWin(chapters, count, ci, continuous, prefetch),
[chapters, count, ci, continuous, prefetch],
);
const locked = !!chapters && chapters.length >= 2 && !continuous;
// 容器尺寸自适应:回调 ref 在滚动盒真正挂载时才观测(等页数请求期间的早退渲染里没有这个节点)
const roRef = useRef<ResizeObserver | null>(null);
function attachBox(el: HTMLDivElement | null) {
boxRef.current = el;
roRef.current?.disconnect();
roRef.current = null;
if (!el) return;
const ro = new ResizeObserver(() => {
setWidth(Math.min(el.clientWidth, MAX_W));
setVh(el.clientHeight);
});
ro.observe(el);
roRef.current = ro;
}
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
useEffect(() => {
if (restored.current || !count || !boxRef.current) return;
restored.current = true;
const p = Number(initialLocator?.page);
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false);
}, [count, initialLocator, ph]);
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
useEffect(() => {
if (pending.current === null) return;
const el = boxRef.current;
if (!el) return;
const t = pending.current;
pending.current = null;
el.scrollTop = ph.offset(t);
}, [ci, ph]);
// 滚动事件按帧合并;预取等滚动停稳再做——拖拽途中逐事件拉图=带宽/解码风暴
function onScroll() {
if (scrollRaf.current) return;
scrollRaf.current = requestAnimationFrame(() => {
scrollRaf.current = 0;
const el = boxRef.current;
if (!el) return;
const t = el.scrollTop;
setTop(t);
if (count) {
curRef.current = ph.pageAt(t + vh / 2);
if (!locked) setCi(chapterIndexAt(chapters, curRef.current)); // 连读/扁平:当前章随滚动走(目录计数高亮)
saver.report({ page: curRef.current }, (curRef.current + 1) / count);
}
clearTimeout(idle.current);
idle.current = setTimeout(() => {
for (let j = curRef.current + 1; j <= Math.min(count - 1, curRef.current + 5); j++)
fetchObjectUrl(formatPageUrl(book.page_url_fmt ?? "", j)).catch(() => {});
}, 250);
});
}
useEffect(
() => () => {
cancelAnimationFrame(scrollRaf.current);
cancelAnimationFrame(shiftRaf.current);
clearTimeout(idle.current);
},
[],
);
function applyShift(d: number) {
shift.current += d;
if (shiftRaf.current) return;
shiftRaf.current = requestAnimationFrame(() => {
shiftRaf.current = 0;
const el = boxRef.current;
if (el && shift.current) el.scrollTop += shift.current;
shift.current = 0;
});
}
const cur = ph.pageAt(top + vh / 2);
useEffect(() => {
if (toc) activeRow.current?.scrollIntoView({ block: "center" });
}, [toc]);
function scrollToPage(i: number, smooth: boolean) {
const el = boxRef.current;
if (!el) return;
el.scrollTo({ top: ph.offset(Math.max(w.start, Math.min(w.end - 1, i))), behavior: smooth ? "smooth" : "auto" });
}
// 统一定位入口:锁章下目标页在窗外 = 显式切章(滑条/书签/目录/章末卡片/恢复进度皆此一路径)
function goPage(i: number, smooth = true) {
const t = Math.max(0, Math.min(count - 1, i));
chrome.show();
if (locked && (t < w.start || t >= w.end)) {
setCi(chapterIndexAt(chapters, t));
pending.current = t;
return;
}
scrollToPage(t, smooth);
}
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
function turnScreen(d: number) {
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
}
// 点按两侧翻屏,点中间唤出工具栏(微信读书/Mihon 式手势区)
function onZoneClick(e: MouseEvent<HTMLDivElement>) {
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
if (window.getSelection()?.toString()) return;
const r = e.currentTarget.getBoundingClientRect();
const x = (e.clientX - r.left) / r.width;
if (x < 0.28) turnScreen(-1);
else if (x > 0.72) turnScreen(1);
else chrome.toggle();
}
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
if (countQ.isPending && !!book.pages_url)
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
if (countQ.isError || !book.page_url_fmt)
return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
</div>
);
const fmt: string = book.page_url_fmt;
const [a0, b0] = ph.range(top, vh, 2);
const a = Math.max(a0, w.start);
const b = Math.min(b0, w.end); // 可见页永不超过章末;窗外页在隐藏预读层
const contentH = locked ? ph.offset(w.end) + vh : ph.total();
function measure(i: number, h: number) {
const el = boxRef.current;
const topY = el ? el.scrollTop : 0;
const oldEst = ph.estHeight;
const topOfI = ph.offset(i);
const d = ph.set(i, h);
if (el && d !== 0 && topOfI < topY) applyShift(d); // 视口上方页高变化 → 补偿滚动
// 让未量高页的估高收敛到实测页高:同开本漫画量一页后 d≈0,拖拽滚动条不再逐帧打架
if (width > 0 && Math.abs(h - oldEst) / oldEst > 0.03) {
const n = ph.setEst(h, topY);
if (el && n) applyShift(n * (h - oldEst));
ratio.current = h / width;
}
bump((x) => x + 1);
}
return (
<div
tabIndex={0}
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
className="relative h-full"
onKeyDown={(e) => {
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
else return;
e.preventDefault();
}}
>
<div ref={attachBox} onScroll={onScroll} onClick={onZoneClick} className="h-full overflow-y-auto">
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
const i = a + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
{locked && (
<div
className="absolute left-0 grid w-full place-items-center"
style={{ top: ph.offset(w.end), height: vh }}
>
{w.end >= count ? (
<span className="text-sm text-stone-500">— 全书完 —</span>
) : (
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
本章完 · 下一章 →
</button>
)}
</div>
)}
{locked && w.mountEnd > w.end && (
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
const i = w.end + k;
return (
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) }}>
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
</div>
);
})}
</div>
)}
</div>
</div>
{chrome.on ? (
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
<div className="flex items-center gap-3">
<input
type="range"
min={0}
max={Math.max(0, count - 1)}
value={scrub ?? cur}
onChange={(e) => {
const v = Number(e.target.value);
setScrub(v);
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
}}
onPointerUp={() => setScrub(null)}
onPointerCancel={() => setScrub(null)}
onBlur={() => setScrub(null)}
className="rd-range"
aria-label="页码跳转"
/>
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
</span>
</div>
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
<button
className={"rd-btn" + (continuous ? " rd-btn-on" : "")}
aria-pressed={continuous}
onClick={() => setContinuous(!continuous)}
>
连读{continuous ? "·开" : "·关"}
</button>
{locked && (
<button
className="rd-btn"
aria-label="预读:锁定章节末尾时提前挂载并解码其后 N 章"
onClick={() => setPrefetch((p) => (p + 1) % 4)}
>
预读 {prefetch} 章
</button>
)}
{bm.btn}
{chapters && (
<>
<button className="rd-btn" aria-expanded={toc} aria-controls="cbz-toc" onClick={() => setToc((v) => !v)}>
目录
</button>
<span className="text-xs tabular-nums opacity-60">
{ci + 1}/{chapters.length}
</span>
<button className="rd-btn" disabled={ci === 0} onClick={() => goPage(chapters[ci - 1].start)}>
← 上一章
</button>
<button
className="rd-btn"
disabled={ci === chapters.length - 1}
onClick={() => goPage(chapters[ci + 1].start)}
>
下一章 →
</button>
</>
)}
</div>
</div>
) : (
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
<span>
{cur + 1}/{count}
</span>
<span className="opacity-60">{Math.round(((cur + 1) / Math.max(1, count)) * 100)}%</span>
</div>
)}
{toc && chapters && (
<>
<div className="fixed inset-0 z-10" aria-hidden="true" onClick={() => setToc(false)} />
<nav
id="cbz-toc"
aria-label="章节目录"
className="rd-divider absolute inset-y-0 left-0 z-20 w-64 overflow-y-auto border-r bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<ul className="space-y-0.5">
{chapters.map((c, i) => (
<li key={i}>
<button
ref={i === ci ? activeRow : undefined}
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
onClick={() => {
goPage(c.start);
setToc(false);
}}
>
{i + 1}. {c.title}
</button>
</li>
))}
</ul>
</nav>
</>
)}
{bm.panel}
</div>
);
}
```
- [ ] **Step 2: 静态检查 + 单测全绿**
Run: `docker exec -w /app booklib_web_1 npm run -s check`
Expected: tsc 无错误、vitest 全绿、vite build 成功。若 tsc 报 `chapters!` 收窄或 unused import,就地修正后重跑。
- [ ] **Step 3: 追加 changelog**(`docs/CHANGELOG_web.md`,插入到 `### Added / 新增` 之后的**最上方**,与下一条之间留一个空行;英中文案见 spec《文档 / Docs》节,内容如下)
```markdown
- 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 章可配置、提前挂载解码实现切章零白屏;移除原 连读/整页/适高 三档循环。
```
- [ ] **Step 4: Commit**
```bash
git add frontend/src/readers/CbzReader.tsx docs/CHANGELOG_web.md
git commit -m "feat(cbz): chapter-scoped continuous reading — lock scroll at chapter end, screen paging, prefetch next N chapters; drop 连读/整页/适高 modes"
```
---
### Task 3: 浏览器验收(真实运行环境点验)
**Files:**
- 无新文件;发现问题就地回改 Task 1/2 的文件并补 commit。
**Interfaces:**
- Consumes: Task 2 产物;dev 栈(`deploy/docker-compose.dev.yml`,web 热更新无需重建)。
- [ ] **Step 1: 确认 dev 栈在跑**(`docker compose -f deploy/docker-compose.dev.yml ps`;未跑则 `up -d`),打开 `http://localhost:5173`(或 compose 映射端口),登录。
- [ ] **Step 2: 分章包锁章验收**——打开一本按目录分章的漫画:向下滚到本章底部出现「本章完 · 下一章」卡片且继续滚不进入下一章;点卡片/下一章秒开无白屏(预读 2 章生效);「预读」按钮循环 0–3 且刷新后记忆。
- [ ] **Step 3: 连读验收**——点「连读」变「连读·开」:滚动可直接滑过章界进下章,目录计数随滚动走;刷新页面开关状态保持。
- [ ] **Step 4: 定位类入口验收**——拖滑条到别章页 = 跳章成功;书签 seek 跳他章;重进书恢复到他章原位;点按左右缘/←→ 翻一屏(非翻一张图)。
- [ ] **Step 5: 扁平包回归**——无目录的漫画:工具栏无连读/预读按钮、正常滚动到底只有全书末尾(无章末卡片);图片加载失败重试可用。
- [ ] **Step 6: 移动端手感抽查**——窄屏(DevTools 手机模拟)滚到章底回弹正常、无逐帧跳动;console 无 React 报错。
- [ ] **Step 7: 全量回归收尾**——`docker exec -w /app booklib_web_1 npm run -s check` 绿;有修复则 `git add -A && git commit -m "fix(cbz): <具体现象>"`。
---
## Self-Review 记录
1. **Spec 覆盖**:模式移除✓(Task 2 删除三档)、锁章滚动边界✓(contentH=章末+卡片)、翻屏✓(turnScreen)、章末卡片✓、滑条/书签/目录跨章=显式跳章✓(goPage 统一入口)、预读隐藏层✓(mountEnd + overflow:hidden 层)、步进器/持久化✓、扁平包✓(chapterWin 全书 + 按钮隐藏)、恢复进度✓(goPage 路径)、量高学习保留✓(measure)、changelog✓。
2. **占位符**:无 TBD/similar-to;测试与实现代码完整。
3. **类型一致**:Task 1 导出 `chapterIndexAt(chs,page)` / `chapterWin(chs,count,ci,continuous,prefetch)` 与 Task 2 import 一致;`ChapterWindow` 字段名与 Task 2 使用一致。
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
File diff suppressed because it is too large Load Diff
@@ -77,7 +77,7 @@ POST /api/users ★ {username,password,role}
DELETE /api/users/{id} ★
GET /api/libraries
POST /api/libraries ★ {name,root_path}
POST /api/libraries ★ {name} → root_path 由服务端生成(BooksDir/清洗后的库名)
POST /api/libraries/{id}/scan ★ → 202 异步
POST /api/libraries/{id}/upload multipart ★ → 202
@@ -0,0 +1,114 @@
# Docker 部署规范化改写 — 设计文档
日期:2026-09-07 · 状态:已确认(用户批准) · 范围:仅部署形态,不改业务架构
## 1. 背景与目标
现有部署(根 `docker-compose.yml` + `deploy/docker-compose.dev.yml`)与团队 Docker 部署规范不一致。本改造把 booklib 重写为规范形态:
- dev = `deploy/docker-compose.dev.yml`,release = `deploy/docker-compose.yml`(根 compose 删除)
- 两模式共享 named volumes,`down -v` 才清数据
- 配置由 `prepare.sh` 从模板渲染后 `:ro` 挂载进容器
- dev 源码热挂载 + `target: dev` 镜像 + delve :2345;release 用 `*.prod` Dockerfile(`target: runner`)编译产物,不挂源码
- 文件存储挂 `deploy/api/storage`(对齐 `deploy/{service_name}/storage` 约定)
**非目标**:不引入 rabbitmq/consul/minio/ES,不拆分 worker,不改业务代码(除 §7 列出的两处适配)。
## 2. 目录布局
```
deploy/
docker-compose.yml # release
docker-compose.dev.yml # dev(重写:四服务全容器化)
prepare.sh # 渲染配置,up 之前必须执行
.env.example # 从仓库根迁入;本地 .env 同位置(项目目录=deploy/,插值才生效)
templates/
nginx.conf.tpl # nginx 主配置(events/http、include conf.d、日志与 pid 路径)
default.conf.tpl # server 块(CSP、/api 反代、SPA fallback、resolver 占位行)
redis.conf.tpl # maxmemory / policy
nginx/nginx.conf # 渲染产物(gitignore)
nginx/conf.d/default.conf # 渲染产物(gitignore)
redis/redis.conf # 渲染产物(gitignore)
logs/nginx/ # prepare.sh 创建;宿主机收集 nginx access/error log(gitignore)
api/storage/ # 书库+缓存绑定挂载(gitignore,保留 .gitkeep)
Dockerfile.api.dev # 多阶段,target: dev(golang + delve)
Dockerfile.api.prod # 多阶段,target: runner(静态二进制)
Dockerfile.web.dev # 多阶段,target: dev(node,跑 vite)
Dockerfile.web.prod # 多阶段,target: runner(构建 SPA + nginx)
entrypoint-resolver.sh # 保留并调整:见 §5
```
删除:根 `docker-compose.yml`、根 `.env.example`、旧 `deploy/Dockerfile.api`、旧 `deploy/Dockerfile.web`、旧 `deploy/nginx.conf`。`library/` 目录废弃(当前为空且 gitignore)。
## 3. 项目名与共享卷
- 两个 compose 顶层显式 `name: booklib`,与运行目录解耦。
- 卷:`postgres_data`、`redis_data` → 实际名 `booklib_postgres_data`、`booklib_redis_data`;两文件完全同名,dev/release 看到同一份数据。仅 `down -v`(rebuild/clear)清除。
- redis 按规范挂 `redis_data` 到 `/data`(原「redis 不落盘」设计让位于规范;只是多持久化一份可再生缓存,无功能副作用)。
- 旧卷迁移(README 记录一次性命令):
`docker run --rm -v book-comic-library_pgdata:/from -v booklib_postgres_data:/to alpine cp -a /from/. /to/`
旧 `cache` 卷为封面/解压缓存,可直接丢弃(自动重建)。
## 4. dev 模式(`deploy/docker-compose.dev.yml`)
| 服务 | 镜像/build | 挂载 | 端口(宿主:容器) | 启动 |
|---|---|---|---|---|
| postgres | postgres:16-alpine | volume `postgres_data` | 5432:5432 | 默认 |
| redis | redis:7-alpine | `./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro`、`redis_data:/data` | 6379:6379 | `redis-server /usr/local/etc/redis/redis.conf`(移除命令行 maxmemory) |
| api | `Dockerfile.api.dev` target `dev` | `../backend:/app`、`./api/storage:/data/books` | 2345:2345(delve) | `go mod download && dlv debug ./cmd/server --headless --listen=0.0.0.0:2345 --api-version=2 --log` |
| web | `Dockerfile.web.dev` target `dev` | `../web:/app` + 匿名卷 `/app/node_modules`(镜像内依赖不被覆盖) | 5173:5173 | `npm run dev -- --host 0.0.0.0` |
- api env:`DATABASE_URL=postgres://lib:lib@postgres:5432/lib?sslmode=disable`、`REDIS_URL=redis://redis:6379`、`BOOKS_DIR=/data/books`、`CACHE_DIR=/data/books/cache`、`JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD/SCAN_INTERVAL_SEC` 自 `.env` 插值。
- 启动门控:api `depends_on` postgres `service_healthy` + redis `service_started`;web 不依赖 api 健康(vite 反代容忍后端重启)。
- 热更新语义:改 Go/TS 源码 → `docker compose ... restart api|web` 即生效(go run/dlv 重编译、vite 本身 HMR),全程无需 rebuild 镜像。
- dev 不起 nginx(vite 代理承担 `/api`)。因此 nginx 配置/log 绑定挂载只在 release 出现——这是对规范「两模式都挂 nginx 配置」的有意偏差:dev 无 nginx 进程,挂之无意义。
- 删容器用 `down`,不用 `rm`,避免匿名 node_modules 卷成为孤儿。
## 5. release 模式(`deploy/docker-compose.yml`)
- web:`Dockerfile.web.prod` target `runner`;宿主端口 `${WEB_PORT:-8080}:80`;挂载
`./nginx/nginx.conf:/etc/booklib/nginx.conf:ro`、`./nginx/conf.d/default.conf:/etc/booklib/default.conf:ro`、`./logs/nginx:/var/log/nginx`。
配置挂到 `/etc/booklib/` 暂存而非直接挂 `/etc/nginx/conf.d/`,因为 `:ro` 绑定挂载下 entrypoint 的 sed 无法原地改写。
- `entrypoint-resolver.sh` 调整:启动时 `cp /etc/booklib/default.conf → /etc/nginx/conf.d/default.conf`,再按 `/etc/resolv.conf` 首个 nameserver sed 注入 resolver,然后 exec nginx。镜像不再 COPY baked 配置(`Dockerfile.web.prod` 移除该 COPY;若忘跑 prepare.sh,文件缺失会让 compose 启动即报错,符合规范「stale/缺失 = 忘了重跑 prepare」)。
- api:`Dockerfile.api.prod` target `runner`;无源码挂载、无配置挂载(本项目 api 配置全部来自 env,符合「Go 服务只挂配置文件」——无可挂项即为不挂);`./api/storage:/data/books` 与 dev 同路径同数据;`BOOKS_DIR=/data/books`、`CACHE_DIR=/data/books/cache`。
- postgres/redis:不暴露宿主端口,仅容器网络;redis 同样挂渲染 `redis.conf` + `redis_data`。
- 渲染产物 bind mount 集合与规范对齐:nginx 两份 + redis.conf + logs/nginx。
## 6. prepare.sh 与模板变量
`deploy/prepare.sh`(`set -eu`,在脚本自身目录执行):
1. 若存在 `deploy/.env` 则逐行解析 `KEY=VALUE` 注入渲染环境;未定义变量用下表默认值兜底。
2. `mkdir -p nginx/conf.d redis logs/nginx api/storage`。
3. 用 sed 把模板中 `{{VAR}}` 替换为实际值,渲染到对应产物路径(幂等,可反复执行)。
| 变量 | 默认值 | 用途 |
|---|---|---|
| `WEB_PORT` | 8080 | release 宿主端口 |
| `NGINX_CLIENT_MAX_BODY_SIZE` | 200m | default.conf.tpl |
| `REDIS_MAXMEMORY` | 128mb | redis.conf.tpl |
| `REDIS_MAXMEMORY_POLICY` | allkeys-lru | redis.conf.tpl |
| `DELVE_PORT` | 2345 | dev compose 的 delve 宿主端口(非模板变量,供 `${DELVE_PORT:-2345}` 插值) |
现有 `.env` 键(JWT_SECRET/ADMIN_USER/ADMIN_PASSWORD/SCAN_INTERVAL_SEC)不变;新增上表键写入 `.env.example`。
## 7. 周边代码改动(最小集)
1. `web/vite.config.ts`:proxy target 改为 `process.env.VITE_PROXY_TARGET ?? "http://localhost:8080"`;dev compose 给 web 服务注入 `VITE_PROXY_TARGET=http://api:8080`。宿主机跑 vite 的旧习惯不受影响。
2. `scripts/smoke.sh` / `scripts/smoke-web.sh`:`. ./.env` 改为优先 `deploy/.env`、回退根 `.env`;`smoke.sh` 里依赖旧 `./library:/data/books` 绑定的宿主路径 `library/smoke-books`(建目录与删除断言两处)同步改为 `deploy/api/storage/smoke-books`。
3. `.gitignore`:删 `library/`,增 `deploy/.env`、`deploy/nginx/`、`deploy/redis/redis.conf`、`deploy/logs/`、`deploy/api/storage/*`(`!.gitkeep`)。
4. `README.md`:dev/release 命令全部重写(`docker compose -f deploy/docker-compose.yml|docker-compose.dev.yml`,先 `deploy/prepare.sh`),旧卷迁移说明,dev 调试(delve :2345 / IDE attach 示例)。
## 8. 错误处理
- 未跑 prepare.sh 就 `up`:compose 绑定挂载源不存在 → Docker 直接报错(不是静默空目录);`prepare.sh` 本身缺 `.env` 不报错(用默认值渲染),缺模板则报错退出。
- api 连不上 PG:healthcheck 门控 + 后端已有启动失败即退出的行为不变。
- delve 端口冲突:`2345` 可经 `.env` 变量 `DELVE_PORT`(默认 2345)覆盖。
## 9. 验收
1. `deploy/prepare.sh && docker compose -f deploy/docker-compose.yml up -d --build` → `bash scripts/smoke.sh` 与 `bash scripts/smoke-web.sh` 全绿(端口语义与现在一致:宿主 8080)。
2. `docker compose -f deploy/docker-compose.dev.yml up -d --build` → `curl localhost:5173` 返回 vite 页面、`curl localhost:5173/api/healthz` 经代理 200;宿主 `nc -z localhost 2345` 通。
3. 数据共享验证:dev 建库上传 → dev `down` → release `up` → 数据可见;`docker volume ls | grep booklib_` 两卷名一致。
4. 规范符合性核对表逐项打勾(挂载、target、named volume、prepare 产物、存储路径)。
5. 回归:`cd backend && go vet ./... && go test -p 1 -count=1 ./...`、`cd web && npm run check` 全绿。
@@ -0,0 +1,77 @@
# 书签系统(备注 + 跳转)设计 / Bookmarks (notes + jump-to) design
日期 2026-09-08。分支 `feat/bookmarks`。状态:已获用户批准(会话内确认)。
## 目标 / Goal
阅读任意格式的书时可「加书签」,书签携带备注,可从书签列表一键跳转(seek)到原位。
Bookmarks with editable notes and one-click jump-to-position for all reader formats.
## 决策记录 / Decisions
- 个人可见(与 progress 同款 user 隔离),不做共享。用户选定。
- 阅读器内闭环(工具条按钮 + 侧栏),不做全局书签页。用户选定。
- 全格式(cbz/txt/md/pdf/epub):locator 存取/恢复机制四类 reader 均已存在,书签复用。用户选定。
- 否决方案:书签数组塞 progress JSONB(整包重写、并发互踩);纯 localStorage(不跨设备、与服务端进度先例分裂)。
- 删书不级联清书签:与 `reading_progress` 既有行为一致(孤儿行 JOIN 不中即无害)。
## 数据模型 / Schema
`backend/internal/db/schema.sql` 追加(新表,无迁移痛):
```sql
CREATE TABLE IF NOT EXISTS bookmarks (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id BIGINT NOT NULL,
book_path TEXT NOT NULL,
locator JSONB NOT NULL,
percent DOUBLE PRECISION NOT NULL DEFAULT 0,
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now());
CREATE INDEX IF NOT EXISTS bookmarks_user_book_idx
ON bookmarks (user_id, library_id, book_path);
```
定位键与 progress 相同 `(user_id, library_id, book_path)`;`locator` 各格式形状不变
(cbz/pdf `{page}`,txt/md `{ch,scrollFraction}`,epub `{cfi}`)。
## API(source of truth: `cmd/webui/api/router.go`)
| 方法/路径 | 语义 | 错误 |
|---|---|---|
| `GET /api/books/:id/bookmarks` | 本书书签,percent 升序 | book 不存在 404 |
| `POST /api/books/:id/bookmarks` `{locator,percent,note?}` | 建,201 返回全字段 | locator 空/percent∉[0,1]/note>500 字符 → 400 |
| `PATCH /api/bookmarks/:id` `{note}` | 仅改备注 | 非本人/不存在 → 404 |
| `DELETE /api/bookmarks/:id` | 删,204 | 非本人/不存在 → 404 |
- (library_id, book_path) 由服务端从 :id 解析,客户端不传路径。
- 全部登录可见(admin 不特殊);「非本人」统一 404,不泄露存在性。
- store 层新增 `ListBookmarks/InsertBookmark/UpdateBookmarkNote/DeleteBookmark`,均带 user_id 条件。
## 前端 / Frontend
- `useProgressSaver` 暴露 `capture(): {locator, percent} | null`——每次 `report()` 记下最新值,
打开时以该书已有进度(initialLocator/percent)播种。书签 = 当前进度快照 + 备注。
- `components/Bookmarks.tsx` 共享组件(目录侧栏同款交互/样式 rd-*):
- 工具条「书签」按钮 → 弹出备注输入(可空,Enter 保存) → POST → 刷新列表。
- 侧栏行:`percent%` + 备注(空则省略) + 时间;点行 = `seek(locator)` 并收起;✎ 行内改备注;✕ 删除。
- 各 reader 提供 `seek(locator)`:cbz `jump(page)` 已有;txt `goChapter+scrollFraction` 已有;
epub 复用初始恢复路径(`display(cfi)`);pdf 补一个滚动到页的函数(~5 行)。
## 错误处理 / Errors
- 网络失败:toast 报错,列表不半更新(react-query invalidate 全书)。
- 竞态(两端同时加):无幂等要求,各自成条。
- percent 越界/locator 非法:400,前端表单先行禁用空提交。
## 测试 / Tests
- 后端 `handlers/bookmarks_test.go`:CRUD 全链路、他人 404、note 超长 400、book 不存在 404、
percent 越界 400、列表排序。
- 前端:`npm run check` 绿;真实浏览器点验(加/跳/改/删)。不为组件引入新测试框架。
- 回归:`go vet` + `go test -p 1 ./...`。
## 不做 / Non-goals
全局书签页、书详情页书签区、去重、标签/分组、跨用户共享、书签导出。有真实需求再做。
@@ -0,0 +1,73 @@
# CBZ 章节化连续阅读(锁章 + 预读)设计 / CBZ chapter-scoped continuous reading design
日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(会话内确认)。
## 目标 / Goal
漫画(CBZ)阅读以「章」为单位:一章内无论多少图都视为一段连续的整章内容,
默认不能靠上下滑动越过章界进入下一章(只能点「下一章」),可开「连读」让滑动贯穿章节。
去掉现有 连读/整页/适高 三档概念。锁章时预渲染(挂载+解码)其后 N 章(用户可配置,默认 2),
切章零白屏。
CBZ reading is chapter-scoped: images within a chapter flow continuously; by default scrolling cannot cross a chapter boundary (only the 下一章 button can); an optional 连读 mode lets scrolling flow into following chapters. The three-mode cycle (连读/整页/适高) is removed. The next N chapters (user-configurable, default 2) are pre-rendered while locked so chapter switches are instant.
## 决策记录 / Decisions
- 章节内显示 = 按容器宽度(≤720px)连续滚动、无 snap、无适高一屏一页。用户选定(方案 A)。
- 翻页手势/按键 = 章内翻一屏(`scrollBy ±vh`),不再按图跳页。用户选定。
- 采用「按章节作用域渲染」(方案 A),否决:全书渲染+滚动钳制(手感生硬、浪费加载)、
一章一挂载(书签/进度/量高学习跨章迁移复杂、切章闪烁)。
- 预读实现为隐藏层挂载解码(不增加可滚动高度),不违反锁章语义。用户要求「可预渲染下两章、可配置」。
- 进度滑条/页码/书签/服务端进度均仍为全书页号,locator 形状 `{page}` 不变,无后端/API 改动。
- 扁平包(chapters 为 null)= 整本一章:章按钮与连读开关隐藏,锁章/预读无意义,行为即纯连续滚动。
## 前端 / Frontend(全部改动在 `frontend/src`,纯前端)
### 状态与持久化
- 删除 `PageMode`/`MODES`/`MODE_LABEL`/`useStoredMode` 及 `cbz-mode` 键;PageImg 去掉 `fitH` 分支;
滚动盒去掉 `snap-y snap-proximity`。
- `localStorage("cbz-continuous")`:连读开关,默认关(锁章)。
- `localStorage("cbz-prefetch")`:预读章数 0–3,默认 2。工具栏步进器按钮循环 0→1→2→3→0。
### 区间几何
- 当前章 `ci` 由滚动位置(现 `cur` 所在页 → 章)推出,章起点 `chapters[ci].start`。
- `PageHeights` 仍覆盖全书 `count` 页(复用现有量高/估高/`applyShift` 补偿逻辑)。
- 挂载上限 `mountEnd`:锁章 = 第 `ci+1+prefetch` 章起点(越界取 count);连读 = count。
- 滚动终点 `scrollEnd`:锁章 = `ph.offset(章末) + 章末卡片高`;连读 = 全书末尾。
- 超出 `scrollEnd` 的预读页渲染进一个 `height:0; overflow:hidden` 的绝对定位隐藏层
(子页仍按 `ph.offset(i)` 定位、`aria-hidden`):blob 经 `fetchObjectUrl` 缓存、`<img>` 挂载即解码,
但不参与滚动几何。切章后这些页落入可见区间即直接上屏。
- 纯计算抽为 `lib/virt.ts` 导出 helper(如 `chapterRange(chapters, count, ci, continuous, prefetch)`
→ `{mountStart, mountEnd, scrollEndPage}`),vitest 覆盖。
### 交互
- 点按左右侧区/←→/PageUp·Down:`scrollBy(±vh, smooth)`;锁章时滚动高度天然停在章末卡片。
- 点中间仍唤出工具栏(不变)。
- 章末卡片:锁章时本章末尾一屏「本章完 · 下一章 →」(最终章显示「全书完」),点卡片=切章。
- 切章入口(上一章/下一章按钮、目录行、书签 seek、章末卡片、滑条拖入他章区间):
设 `ci` 并 `jump(page)`;滑条跨章视为显式定位,不算「滑动越界」。
- 恢复进度:由 `initialLocator.page` 反推所在章后定位。
- 闲时「停滚 250ms 预取后 5 页」保留。
## 错误处理 / Errors
- 无新增失败面:页数接口、鉴权取图、失败重试(「本页加载失败」)均沿用。
- `cbz-prefetch` 值非法(手改 localStorage)→ 回退默认 2;`cbz-continuous` 非 `"1"` → 关。
## 测试 / Tests
- `lib/virt.ts` helper 表驱动单测:锁/连读 × 预读 0–3 × 首章/末章/单章扁平包 × 边界页;
断言 mountEnd/scrollEnd 不越过滚动锁、预读不超过 count。
- 现有 `virt` 用例保持绿;`npm run check` 绿。
- 真实浏览器点验:锁章滚不到下章、开连读可滑过、预读下切章无白屏、扁平包无章 UI。
## 文档 / Docs
- `docs/CHANGELOG_web.md` 新增条目(英中各一行),不重复进 `docs/CHANGELOG.md`。
## 不做 / Non-goals
- 后端章节结构改动、章内页号重映射、PDF/EPUB/TXT 阅读器改动、横翻 RTL 模式、
长按拖拽越章「窥视」下一章。
@@ -0,0 +1,97 @@
# 全站现代化重构(shadcn/radix 设计系统 + 公共表面)设计 / Modern site-wide redesign (shadcn/radix) design — phase 1
日期 2026-09-08。分支 `feat/ui-redesign`。状态:已获用户批准(五节设计逐节确认)。
## 目标 / Goal
以 shadcn/radix 体系从零重做全部产品表面的布局与交互(不沿袭现有布局/交互,仅阅读器的翻页/翻章语义保留),
建立 light/dark 双主题设计系统;功能面与现状等价;移动端达到真正可用。
本期(phase 1)= 设计底座 + 登录/应用壳/书架/admin 两页;阅读器 chrome 迁移为 phase 2(另立 spec)。
## 决策记录 / Decisions
- 用户选定:方案 C(shadcn/radix),视觉基调=极简书架风(Notion/Linear/豆瓣读书),主题=双主题开关(默认 system)。
- 功能等价重做;不做功能增减。明确否决/推迟:视图密度开关(=新功能,不做)、阅读器本期不动(phase 2)、
命令面板/键盘优先导航(不适合媒体浏览)。
- 否决方案:仅换皮保结构(四项痛点都不回答)、手写 token 层不引框架(交互原语 a11y 自理成本高)。
- `components/icons.tsx`(自研 SVG)删除,由 `lucide-react` 取代。
- `components/ui.ts` 不整体删(phase-1 的 `/book/:id` 各表面仍引用):**收缩为 `btn`/`btnGhost`/`formatBadge`
三个 deprecated 导出**供阅读器侧继续使用,phase 2 随阅读器迁移一并删除;其余导出(`input`/`card`/`pill`/
`pillActive`/`btnPrimary`)随公共表面重做删除;`formatSize` 属数据格式化,迁 `lib/format.ts`。
- `/book/:id`(`pages/Reader.tsx` + 四 reader 组件 + `Bookmarks` 面板)本期整体不重构、不套壳,维持现状。
例外边界:`Cover`/`Toaster` 是跨期共享组件,phase 1 改写时**保持 props 兼容**(EpubReader/App 等旧调用零改动),
仅视觉入新 token。
- 原生 `confirm()`(Shelf 删书、Users 删用户)→ radix AlertDialog。
- API/路由/数据流零改动;后端不在本期范围。
## 技术底座 / Foundation
- React 19.2 + Tailwind v4.3(`@tailwindcss/vite` 已在)+ react-router 7。新依赖(容器内安装,提交 lockfile):
`radix-ui`(统一新包,按需 import 子模块)、`class-variance-authority`、`clsx`、`tailwind-merge`、
`lucide-react`、`tw-animate-css`。
- `components.json` 放 `frontend/`,alias: `@/* → src/*`(与 tsconfig paths 同步)。
- 工具:`src/lib/utils.ts` 提供 `cn = twMerge(clsx(...))`。
- Token:shadcn 标准语义变量(`--background --foreground --card --card-foreground --popover --primary
--secondary --muted --accent --destructive --border --input --ring --chart-*` + `--radius`),
`@theme inline` 映射进 Tailwind;`:root` = light(纸白中性灰,`--primary` 暖墨黑,唯一彩色点缀朱橙),
`.dark` = 对应暗色套。全站禁止裸 `stone-*`/`amber-*` 直用(阅读器现有类在 phase 2 清理)。
- 主题:`src/components/theme.tsx` ThemeProvider — `"system" | "light" | "dark"` 持久化于
`localStorage("ui.theme")`(默认 system);`index.html` 内联脚本按同 key 预算 class 防 FOUC;
入口=应用壳内 DropdownMenu(lucide Sun/Moon/Monitor,当前值打勾)。
## 应用壳 / AppShell
- 新 `src/components/app-shell.tsx` 包裹 `/` 与 `/admin/*`(登录页不套壳,居中 Card 独立布局)。
- 断点约定(Tailwind 默认值):
- `lg(≥1024)`:固定 `w-60` 左侧栏——品牌字标、导航(书架;admin 另加 库管理/用户管理)、
库筛选列表(全部+各库,来自 `GET /libraries`)、底部 主题切换 + 用户/退出。
- `sm–lg`:图标窄栏(`w-14`,Tooltip 标注)。
- `<sm`:顶部 Header(品牌+搜索入口)+ 底部 Tab 栏(书架 / 管理(admin 可见) / 账户);
账户页=弹出 DropdownMenu(主题、库管理入口、退出)。
- 现 `TopBar.tsx` 删除,职责并入壳。
## 书架页 / Shelf
- 粘性工具条(主列顶部):搜索 Input(沿用 300ms 防抖与 `q` 参数)、排序 DropdownMenu
(新加时间/标题/阅读进度,前端排序,默认新加时间)、分组 SegmentedControl(平铺|按目录,默认平铺)。
- 「继续阅读」:等价保留(progress 前 12 条),桌面横滑大卡轨,移动端降级为单行紧凑卡横滑。
- 网格:`grid-cols-[repeat(auto-fill,minmax(...))]`,卡=封面 3:4 + 标题 + 格式徽章 + 进度细线;
整卡链接进阅读器;操作 DropdownMenu(打开;admin 追加 删除)叠于封面右上,hover/focus 出现。
- 删除确认 = AlertDialog,文案语义与现状一致(提示磁盘文件连带删除、进度保留)。
- 空态/错误重试/骨架屏按新语言重绘;损坏书红徽章保留。
- 数据源不变:`useQuery(["books", lib, q])` + `["progress"]` + 全量 books 缓存复用。
## admin 页 / Admin
- 库管理:库=Card 列表(名称、`#id`、root 路径、扫描按钮、上传 input);上传入口升级=dropzone
(Label+Input 包装,拖拽与点击同效,仍串行 `uploadBook`,toast 用快照计数——保留 2721df8 修复语义)。
- 用户管理:Table(用户名/角色/创建时间/操作)+ 顶部新建行(Input+Select 角色+提交);删除=AlertDialog。
- 权限与错误处理逻辑不变(非 admin 由 AuthContext 路由守卫拦截)。
## 错误处理 / Errors
- 网络错误路径不变(`apiFetch` 抛错→toast);radix 组件不新增失败面。
- `localStorage` 不可用(隐私模式):主题回落 system、渲染不崩(读写包 try/catch)。
## 测试与验收 / Verification
- `npm run -s check`(tsc+vitest+build)绿;既有 33 用例保持绿。
- 纯逻辑新增件(排序比较器、分组视图函数迁自 `lib/group.ts`)补 vitest。
- playwright 走查并截图:375/768/1280 三宽 × light/dark ×(登录→书架→筛选/搜索/排序→
删除确认→admin 两页),核对布局无溢出、焦点环可见、AlertDialog 焦点收拢。
- a11y 基线:导航 landmark、图标按钮 aria-label、`aria-current`、主题切换 `aria-pressed`。
## 文档 / Docs
- `docs/CHANGELOG_web.md` 新增 Changed 条目(英中相邻行):全站 UI 重构为 shadcn/radix 设计系统 +
双主题 + 新应用壳;阅读器注明「交互不变,样式后续」。
## Phase 2(另立 spec 的边界)
- 四阅读器 chrome(工具条/目录/书签面板/主题卡)迁入同一 token + radix 原语(Sheet/Popover);
翻页、翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报逻辑与 localStorage 键原样保留;
「纸/米/夜」重映射为语义变量并与全局主题定联动规则;清理 `rd-*`/裸色类。
## 不做 / Non-goals
- 视图密度开关、虚拟化长列表(当前量级不需要)、i18n、SSR、后端 API 改动、收藏/标签等新功能。
@@ -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

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