16 KiB
后端健壮性(迁移系统 + bug 修复 + 结构重构/接口化)设计 / Backend hardening design
日期 2026-09-14。分支 fix/backend-hardening。状态:已获用户批准(会话内确认)。
本 spec 是项目优化四个子项目中的 ①(其余:② 阅读器改版、③ 功能增强、④ 前端工程质量,各自独立 spec)。③ 依赖本子项目先行(功能变更需要迁移机制)。
目标 / Goal
- schema 变更从「静默不生效」变为安全的有序迁移(多副本并发安全)。
- 修复探索阶段确认的 17 个缺陷(B1–B17),每项先有复现测试。
- 业务逻辑按 AGENTS.md 要求从
cmd/webui/handlers下沉到internal/,并以消费方小接口 + 手写 fake 实现可脱库单测。 - 引入 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)流程:pg_advisory_lock(<固定 int64 常量,定义在 db 包>),defer 解锁——--scale api=N副本串行化;CREATE TABLE IF NOT EXISTS schema_migrations ...;- 基线检测:
schema_migrations为空且to_regclass('books')非空 → 直接登记 0001 已应用,不重跑(老库原地升级); 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;哨兵ErrTooLargeErrIncompleteErrSizeMismatchErrNotFound。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 集合),遍历 ginRoutes()断言与之完全一致、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)自检。
- job
- 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
- A:迁移系统(S1)+ CI workflow(S4)+ B14/B15/B17。门禁:本地全量 + 老库基线验证 + actionlint。
- B:B1–B13,每项复现测试先行。门禁:本地全量(新测试含 fake 前置形态,接口未拆前允许先以集成测试写就,批次 C 迁移为单测)。
- 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/公开面。 - 数据库层性能(连接池参数调优、索引审计)——无证据表明当前是瓶颈。