Files
book-comic-library/docs/superpowers/specs/2026-09-14-backend-hardening-design.md

161 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 后端健壮性(迁移系统 + 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/` 公开面。
- 数据库层性能(连接池参数调优、索引审计)——无证据表明当前是瓶颈。