2026-06-01 13:10:59 +02:00
# 步骤 1 · Alembic 迁移地基 / Step 1 · Migration Foundation
> **可独立执行 / Self-contained.** 完整背景见设计文档 [`llm-integration-design.md`](./llm-integration-design.md) §3;跨步骤约定见 [`implementation-plan.md`](./implementation-plan.md)。
> **前置 / Prerequisite: ** 无(第一步)/ none.
> **产出 / Output: ** 一个可独立合入的 PR; **不改任何业务 schema**。A mergeable PR with **zero business-schema change**.
---
## 目标 / Goal
2026-06-01 16:02:43 +02:00
引入 Alembic 并**安全接管现有生产库**,schema 一点不改,所有现有测试保持绿。**迁移与应用启动分离**:启动只做只读校验 + fail-close,实际迁移由独立、幂等命令 `python -m app.migrate` 执行。
Introduce Alembic and **safely adopt the existing prod DB ** with zero schema change; all tests stay green. **Migration is separated from startup ** : boot only verifies (read-only) and fails closed; the actual migrating is done by a separate idempotent command `python -m app.migrate` .
2026-06-01 13:10:59 +02:00
---
## 必要背景 / Essential Context(仅凭本文件即可执行 / enough to execute from this file)
- **当前没有 Alembic。** 唯一的"迁移"是 `app/db.py::_sync_sqlite_image_columns()` (启动时缺图片列就 `ALTER TABLE ADD COLUMN` )。
No Alembic today; the only "migration" is the hand-rolled image-column sync in `app/db.py` .
2026-06-01 16:02:43 +02:00
- `app/db.py::init_db()` 在 FastAPI lifespan 启动时被 `create_app()` 调用,现在执行 `Base.metadata.create_all()` + `_sync_sqlite_image_columns()` 。**本步把它改成只读校验**(不再在启动时建表/迁移)。相关符号:`Base` 、`engine` 、`SessionLocal` 、`configure_database()` 。
`init_db()` runs at lifespan startup and currently does `create_all()` + the image-column sync. **This step turns it into a read-only check ** (no table creation/migration at boot).
2026-06-01 13:10:59 +02:00
- `tests/conftest.py` 的 `client` fixture: `configure_database(tmp_url)` → `create_app()` (触发 `init_db` )。每个测试用临时 SQLite,互不污染。
- models 在 `app/models.py` : `Box` / `Item` / `SubItem` 三张表;每张含 `image_blob` (BLOB) / `image_mime_type` / `image_width` / `image_height` ,以及 `created_at` / `updated_at` 。
- DB URL 来自 `app/config.py::get_settings().database_url` (默认 `sqlite:///./data/app.db` )。
- **生产库**是当年 `create_all` 建的、**已装上千件数据、没有 `alembic_version` 表**。
### 铁律 / The Invariant(不可违背 / non-negotiable)
- 所有数据库最终收敛到同一个 `head` 。All DBs converge to the same `head` .
- **V1 baseline 必须严格等于"今天的真实 schema"**(三张表 + 现有图片列 + 索引),**不多一列**。新东西放后续 revision。
The V1 baseline must equal **today's actual schema exactly ** — nothing more.
2026-06-01 16:02:43 +02:00
- 以下动作**由迁移命令执行,不在应用启动时** / done by the **migration command ** , not at boot:
- 老库且与 baseline 一致:`stamp V1` (只写版本号,**不建表、不碰数据**)→ `upgrade head` 。
Existing DB matching baseline: `stamp V1` (no DDL, no data change) → `upgrade head` .
- 老库但与 baseline 不一致:**fail-close,不做任何改动**。Mismatched existing DB → fail closed.
- 新库:跑 `V1` (真正建表)→ `upgrade head` 。Fresh DB: run `V1` → `upgrade head` .
2026-06-01 13:10:59 +02:00
---
## 任务 / Tasks
- [ ] `requirements.txt` 增加 `alembic` (钉一个明确版本 / pin a version)。
- [ ] 初始化 Alembic 工程:`alembic.ini` + `migrations/` (含 `env.py` 、`versions/` )。
- [ ] 配置 `migrations/env.py` :
- `target_metadata = app.db.Base.metadata` (确保导入 `app.models` 以注册三张表)。
- `sqlalchemy.url` **从 `app.config.get_settings().database_url` 动态读取 ** ,不写死在 `alembic.ini` 。
- 对 SQLite 设 `render_as_batch=True` (为未来改列/删列预留 batch 能力)。
- [ ] 生成 **V1 baseline 迁移 ** =当前 models 的完整建表(`boxes` /`items` /`subitems` ,含图片列与索引)。做法:对**空库** `--autogenerate` 。
Author V1 by autogenerating against an **empty ** DB.
- [ ] **验证 baseline ** :对一份**生产库副本**跑 `alembic check` ,确认**无差异**(印证可安全 `stamp` ;SQLite 偶有类型亲和/索引命名假差异,人眼复核)。
Verify with `alembic check` against a **copy of the prod DB ** → expect no diff.
2026-06-01 16:02:43 +02:00
- [ ] 新增 `app/migrate.py` ,承担两个职责 / two responsibilities:
- **(A) 迁移命令入口 `python -m app.migrate` (幂等 / idempotent) **:编程方式构造 Alembic `Config` ( `script_location` → 打包进镜像的 `migrations/` , `sqlalchemy.url` = 解析出的 URL),用 SQLAlchemy inspector 分情况:
- 空库 / empty → `command.upgrade(cfg, "head")`
- 老库且与 **baseline(V1) ** 一致 → `command.stamp(cfg, "<V1 rev>")` → `command.upgrade(cfg, "head")`
- 老库但与 baseline 不一致 → **fail-close ** :非零退出 + 清晰日志 + **不做任何改动 **
- 已在 `head` → 空操作、退出 0
- `<V1 rev>` 指 **baseline 这个具体 revision ** ( `down_revision=None` 的那条),不是 `head` 。
- "与 baseline 一致"的判定**对照 baseline(V1) 的预期 schema**(不是 head);SQLite 假差异需容忍或允许人工确认覆盖。
- **(B) 启动校验 `verify_schema_is_current(url)` (只读 / read-only) **:比较 DB 当前 revision 与 `head` ;不一致返回失败/抛错,**绝不改动 DB**。
- [ ] 改 `app/db.py::init_db()` :改为调 `verify_schema_is_current(resolved_url)` —— **一致才放行;不一致 fail-close ** (清晰日志,提示先跑 `python -m app.migrate` )。不再在启动时建表/迁移。**删除** `_sync_sqlite_image_columns()` 。保留 `configure_database()` / engine 装配。
`init_db()` now only verifies and **fails closed ** on mismatch (pointing the user to `python -m app.migrate` ); remove `_sync_sqlite_image_columns()` .
- [ ] `tests/conftest.py` : fixture 改为**先跑迁移命令**把临时库带到 `head` ,再 `create_app()` (这样启动校验通过)。
Fixture runs the migration first, then `create_app()` .
2026-06-01 13:10:59 +02:00
- [ ] `Dockerfile` :加 `COPY alembic.ini .` 与 `COPY migrations ./migrations` (否则容器内无迁移脚本)。
- [ ] CI(可选 / optional):`.github/workflows/test.yml` 加一步 `alembic check` ,防止 model 与迁移漂移。
2026-06-01 16:02:43 +02:00
- [ ] Compose `db-migration` 闸门(可后续 / can be deferred):加一个一次性服务跑 `python -m app.migrate` , `web` 经 `depends_on: condition: service_completed_successfully` 等它成功(见设计 §3.5)。
Add a one-shot `db-migration` service gating `web` (design §3.5); may be deferred.
2026-06-01 13:10:59 +02:00
---
## 涉及文件 / Files
2026-06-01 16:02:43 +02:00
`requirements.txt` 、`alembic.ini` (新)、`migrations/**` (新)、`app/migrate.py` (新)、`app/db.py` 、`tests/conftest.py` 、`Dockerfile` 、(可选)`.github/workflows/test.yml` 、(可后续)`docker-compose.yml` 。
2026-06-01 13:10:59 +02:00
---
## 测试 / Tests
2026-06-01 16:02:43 +02:00
- [ ] 现有 ~83 个测试全绿(fixture 先跑迁移、再起 App,启动校验通过)。
All existing ~83 tests pass (fixture migrates first, then starts the app).
- [ ] **认领老库(2a) ** :构造"有 `boxes` 数据、无 `alembic_version` "的库(可先用 `create_all` 造)→ 跑迁移命令 → 断言数据保留、版本到达 `head` 、未重复建表报错。
Adoption (2a): migrate an un-stamped populated DB → data preserved, version at `head` .
- [ ] **全新库 ** :空 URL → 跑迁移命令 → 三张表存在、版本到 `head` 。
Fresh DB: empty URL → migrate → tables exist, version at `head` .
- [ ] **fail-close(启动) ** : DB 未到 `head` 时 `create_app()` / `init_db()` 启动应 fail-close(抛错/拒绝服务)、不改动 DB。
Startup fails closed when the DB is not at `head` ; DB unchanged.
- [ ] **fail-close( 2b) ** :构造与 baseline 不一致的老库 → 跑迁移命令 → 断言非零退出、DB 不变。
Migration command fails closed on a 2b mismatch; DB unchanged.
2026-06-01 13:10:59 +02:00
---
## 验收 / Acceptance
2026-06-01 16:02:43 +02:00
- 迁移命令:空库建到 `head` ;老库一致则认领并到 `head` ;老库不一致则 **fail-close 不改动 ** ;已在 `head` 则幂等空操作。
Migration command: empty→head; matching existing→adopt+head; mismatch→fail closed; already-at-head→no-op.
- 启动校验:DB 未到 `head` 时**拒绝启动**并输出清晰日志;到 `head` 才正常起。
Startup refuses to boot (clear log) unless the DB is at `head` .
- 模拟老库认领后**数据无损**。Adopted existing-like DB keeps data intact.
2026-06-01 13:10:59 +02:00
- 全部测试绿;schema 与本步骤前**逐列一致**(本步不改业务 schema)。
All tests green; schema identical to before (no business-schema change).
---
## 风险与缓解 / Risks & Mitigations
- **baseline 与现状有偏差 → `stamp` 失真。** 缓解:`alembic check` 对生产副本校验 + 人眼复核 SQLite 假差异。
Baseline drift → `alembic check` against a prod copy + manual eyeball.
2026-06-01 16:02:43 +02:00
- **2b 一致性比对假阳性 → 合法老库被误 fail-close。** 缓解:比对基准用 baseline(V1) 而非 head;容忍已知 SQLite 噪声,或提供"人工确认覆盖"的开关。
2b false positives wrongly fail a legit DB → compare against baseline (not head); tolerate known SQLite noise or offer a manual-confirm override.
2026-06-01 13:10:59 +02:00
- **容器内找不到迁移脚本。** 缓解:确认 `Dockerfile` 已 `COPY` `alembic.ini` 与 `migrations/` ; `script_location` 用绝对/相对镜像 WORKDIR(`/app` ) 正确解析。
Migrations missing in image → ensure they're `COPY` -ed and `script_location` resolves under `/app` .
---
## 相关约定 / Conventions(详见 implementation-plan.md)
- 不主动 push/commit,除非业主要求。Don't push/commit unless asked.
- 实现与设计若有偏差 → 回写设计文档 §3 与仓库简报 `../repository-brief.md` §10。