docs(spec): backend hardening design — migrations, 17 bug fixes, ports/internal restructure, CI
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -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` 下沉)。
|
||||||
|
|
||||||
|
| # | 缺陷(现状文件:行) | 修法 |
|
||||||
|
|---|---|---|
|
||||||
|
| 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/` 公开面。
|
||||||
|
- 数据库层性能(连接池参数调优、索引审计)——无证据表明当前是瓶颈。
|
||||||
Reference in New Issue
Block a user