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

16 KiB
Raw Blame History

后端健壮性(迁移系统 + 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/ 公开面。
  • 数据库层性能(连接池参数调优、索引审计)——无证据表明当前是瓶颈。