Files
home-automation/docs/design/m1-db-consolidation.md
tliu93 b359bbe3bf
pytest / test (push) Successful in 54s
docs: add next-phase roadmap, milestone design docs, and CLAUDE.md
- roadmap.md: M1 (DB consolidation) -> M2 (React SPA) -> M3 (token/mobile)
- docs/design/: agent-pipeline design docs with atomic tasks for M1-M3
- CLAUDE.md: workflow, doc map, commit conventions, review-notes briefing flow
- .gitignore: ignore local review-notes/
2026-06-12 15:37:17 +02:00

349 lines
23 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.
# M1 — 单库化地基(DB Consolidation
> 阅读前提:先读 [`README.md`](./README.md)(协作模型、任务卡格式、校验闸门、数据安全红线)。本文档只展开 M1 的现状、目标与原子任务。
## 1. 目标
把 location、poo 两个独立 SQLite 库合并进 `app.db`,收敛成**单库 + 单 engine + 单 DeclarativeBase + 单 Alembic 链**,清理项目早期散落的数据层代码,并移除 Grafana。历史数据零丢失。
## 2. 现状(实现者可据此工作,不必重新通读全仓库)
**三套数据层(散落点)**
- `app/db.py``Base` + `engine`/`SessionLocal`/`get_db_session` —— 实际绑定 `settings.location_database_url`(即 location 库,命名有误导性)。
- `app/models/base.py`:仅 `from app.db import Base` 转出。
- `app/poo_db.py``PooBase` + `poo_engine`/`PooSessionLocal`/`get_poo_db_session` —— 绑定 `poo_database_url`
- `app/auth_db.py``AuthBase` + 带 `lru_cache``_get_auth_engine` / `get_auth_session_local` / `reset_auth_db_caches` / `get_auth_db_session` —— 绑定 `app_database_url`(真正的 app 库)。
**模型与归属**
- `app/models/auth.py``AuthUser``AuthSession``AuthBase` → app 库)
- `app/models/config.py``AppConfigEntry``AuthBase` → app 库)
- `app/models/public_ip.py``PublicIPState``PublicIPHistory``AuthBase` → app 库)
- `app/models/location.py``Location``Base` → location 库),表 `location`PK(`person`,`datetime`)`latitude`/`longitude` NOT NULL`altitude` nullable
- `app/models/poo.py``PooRecord``PooBase` → poo 库),表 `poo_records`PK(`timestamp`)`status`/`latitude`/`longitude` NOT NULL
- `app/models/__init__.py`:导出除 `PooRecord` 外的模型(`PooRecord` 单独存在)
**三条 Alembic 链**
- `alembic_app.ini` + `alembic_app/``env.py``AuthBase.metadata`),head = `20260429_05_public_ip_monitor`
- `alembic_location.ini` + `alembic_location/`head = `20260419_01_location_baseline`
- `alembic_poo.ini` + `alembic_poo/`head = `20260420_01_poo_baseline`
**adoption / 启动链路**
- `scripts/app_db_adopt.py`(常量 `APP_BASELINE_REVISION = "20260429_05_public_ip_monitor"`
- `scripts/location_db_adopt.py``scripts/poo_db_adopt.py`(含 legacy 校验:`EXPECTED_USER_VERSION`、表结构断言)
- `scripts/run_migrations.py`:依次调用三个 adopt 函数,返回 `{"app","location","poo"}`
- `app/main.py` lifespan`ensure_runtime_dirs`app/location/poo 三路径)、`ensure_auth_db_ready``ensure_location_db_ready``ensure_poo_db_ready`,再起 APScheduler 每 4h 检查 public IP
**依赖与路由**
- `app/dependencies.py``get_auth_db`app session)、`get_db`location session)、`get_poo_db`poo session)、`get_app_settings``get_current_auth_session``get_homeassistant_client``get_ticktick_client`
- `app/api/routes/location.py``POST /location/record`,依赖 `get_db`**无鉴权**
- `app/api/routes/poo.py``POST /poo/record``GET /poo/latest`,依赖 `get_poo_db`**无鉴权**
- `app/api/routes/homeassistant.py`:同时用 `get_db`location)和 `get_poo_db`
**config**
- `app/config.py``app_database_url` / `location_database_url` / `poo_database_url` 三字段 + computed `app_sqlite_path` / `location_sqlite_path` / `poo_sqlite_path`
- `app/services/config_page.py``build_runtime_settings` 用到 `reset_auth_db_caches`;配置页 sections 暴露 `location_database_url` / `poo_database_url`(约 263264 行)
**测试耦合点(M1 必然要改)**
- `tests/conftest.py``test_database_urls` 设三套环境变量;`ready_location_database` / `ready_poo_database` / `auth_database` / `location_client`monkeypatch `app_db.engine`/`SessionLocal`/ `poo_client`monkeypatch `poo_db.poo_engine`/`PooSessionLocal`
- `tests/test_location.py` / `tests/test_poo.py`:用上述 client + 各自 adopt 脚本的 adoption 测试
- `tests/test_deployment.py`:断言 `run_all_migrations()` 返回 `{app,location,poo}` 三库各自 revision;断言 entrypoint 不含 `*_db_adopt`
- `tests/test_homeassistant_inbound.py`monkeypatch `app.poo_db`
- `tests/test_config.py` / `tests/test_public_ip.py` / `tests/test_smtp.py`:硬编码三套 URL / 路径
- `reset_auth_db_caches``conftest``test_app``test_auth``test_deployment``test_ticktick` 引用
## 3. 目标架构(M1 完成态)
**单数据层 `app/db.py`**
```python
class Base(DeclarativeBase): ...
# 绑定 settings.app_database_url 的 cached engine;建连时启用 WALPRAGMA journal_mode=WAL
def get_engine() -> Engine: ...
def get_session_local() -> sessionmaker: ...
def reset_db_caches() -> None: ...
def get_db_session() -> Generator[Session, None, None]: ...
```
- 所有模型(auth / config / public_ip / location / poo)都继承这一个 `Base`
- 删除 `app/auth_db.py``app/poo_db.py``app/models/base.py`
- 依赖收敛为**单一** `get_db`app session);移除 `get_poo_db`、旧 `get_auth_db`
- 一条 Alembic 链(`alembic_app`),`location` / `poo_records` 成为其管理对象;删除 `alembic_location*` / `alembic_poo*`
- `config.py` 只保留 `app_database_url`;移除 location/poo 的 url 与 path。
- `docker-compose.yml` 去掉 grafana service;删除 `grafana/`
- 数据搬迁由 `scripts/migrate_legacy_data.py` 一次性完成(不进 Alembic 链)。
## 4. 任务依赖图
```
T01 (app 链建 location+poo 空表)
├─► T02 (数据搬迁脚本) # 逻辑上需要新表存在
└─► T03 [structural] (统一数据层/模型/依赖/路由)
└─► T04 (lifespan + run_migrations 收敛, 删 adopt 脚本)
└─► T05 (config 去 location/poo url + 配置页 + 测试硬编码)
T06 (删 Grafana) # 独立, 可并行
T07 (文档 + OpenAPI 重导出) # 收尾, 依赖 T03/T04/T05
```
`T01``T06` 无前置可先开;`T02` 依赖 `T01``T03` 依赖 `T01``T04`/`T05` 依赖 `T03``T07` 最后。
---
## 5. 原子任务
### M1-T01 — app 链新增 revision:建 `location` + `poo_records` 空表 `[schema]`
- **Status**: `todo`
- **Depends**: none
- **Context**: 让 app 库的 Alembic 链能建出这两张表,schema 与旧库**完全一致**。本任务只动 schema,不搬数据、不移模型。
**Files**
- `create alembic_app/versions/20260611_06_merge_location_poo_tables.py`
- `modify scripts/app_db_adopt.py`(更新 `APP_BASELINE_REVISION`
**Steps**
1. 新 revision`revision = "20260611_06_merge_location_poo_tables"``down_revision = "20260429_05_public_ip_monitor"`
2. `upgrade()``op.create_table` 手写建 `location``poo_records`,列/约束严格照抄现有 baseline(`location`: person TEXT, datetime TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, altitude REAL nullable, PK(person,datetime)`poo_records`: timestamp TEXT, status TEXT, latitude REAL NOT NULL, longitude REAL NOT NULL, PK(timestamp))。
3. `downgrade()``op.drop_table("poo_records")` + `op.drop_table("location")`
4.`scripts/app_db_adopt.py``APP_BASELINE_REVISION` 更新为新 head。
**Out of scope / 不要碰**
- 不要把 `Location` / `PooRecord` 模型改到 app Base(那是 T03)。
- 不要触碰 `alembic_location*` / `alembic_poo*`T03/T04 删)。
- 不要在本 revision 里写任何数据拷贝。
**Acceptance criteria**
- [ ] 在一个全新临时 app 库上 `command.upgrade(alembic_app head)` 后,`sqlite_master``location``poo_records`、且与旧 baseline 表结构一致(`PRAGMA table_info` 对齐)。
- [ ] `downgrade -1` 能干净回滚这两张表。
- [ ] `APP_BASELINE_REVISION == "20260611_06_merge_location_poo_tables"`
- [ ] 校验闸门全绿(`pytest``test_deployment` 对 app head 的断言仍通过,因为它用的是常量)。
**Reviewer checklist**
- 表结构与旧 baseline **逐列逐约束**一致(类型 TEXT/REAL、nullable、PK 顺序)。
- `down_revision` 正确指向旧 head,链上只有一个 head。
---
### M1-T02 — 数据搬迁脚本 `scripts/migrate_legacy_data.py`
- **Status**: `todo`
- **Depends**: M1-T01
- **Context**: 把旧 `locationRecorder.db` / `pooRecorder.db` 的行幂等拷进 app 库的新表,搬完对账。**不进 Alembic 链**,人工运行一次。
**Files**
- `create scripts/migrate_legacy_data.py`
- `create tests/test_migrate_legacy_data.py`
**Steps**
1. 入口 `migrate_legacy_data(app_url, location_url, poo_url, *, dry_run=False) -> dict`CLI 默认从 env 读三个 url(即便 location/poo url 已从 `Settings` 移除,本脚本可直接读环境变量或接受 `--location-db`/`--poo-db` 参数,保持自包含)。
2. 对每个旧库:若文件不存在 → 该表 `skipped`(**不报错**,保证 CI / 全新部署可安全 no-op)。
3. 拷贝用 SQLite `ATTACH DATABASE '<old>' AS legacy` + `INSERT OR IGNORE INTO main.<table> SELECT <显式列> FROM legacy.<table>`(显式列名,禁用 `SELECT *`)。`INSERT OR IGNORE` 保证幂等(PK 冲突跳过)。
4. 搬完对账:对每张表比对 `源行数``目标行数中来自源的部分`;目标行数 < 源行数则 `raise` 并以非零码退出。
5. `dry_run` 模式只读统计、不写入。
6. 打印每表结果:`{location: {source, copied, skipped, final}, poo_records: {...}}`
**Out of scope / 不要碰**
- **绝不** `os.remove` / 覆盖任何旧文件(数据安全红线)。
- 不修改 Alembic 链,不在 app 启动链路里调用本脚本。
- 不改 `config.py`
**Acceptance criteria**
- [ ] 单测:给定含 N 行的临时旧库 + 已 upgrade 的临时 app 库,运行后 app 库对应表有 N 行;**再运行一次**仍是 N 行(幂等)。
- [ ] 单测:旧库文件不存在时该表返回 `skipped`,不抛异常,app 库该表保持为空。
- [ ] 单测:构造"目标缺行"场景,断言对账失败抛错且退出码非零。
- [ ] 脚本中不出现任何文件删除/覆盖调用(`grep -nE "os\.remove|unlink|shutil|truncate|DROP TABLE" scripts/migrate_legacy_data.py` 为空)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 幂等机制确实是 PK 冲突安全(`INSERT OR IGNORE` 或等价 upsert),不是靠"先清空目标"。
- 对账逻辑会在丢行时**真的中止**(非零退出),不是只打印 warning。
- 列名显式,与两表 schema 完全对应。
---
### M1-T03 — 统一数据层、模型、依赖、路由到单库 `[structural]`
- **Status**: `todo`
- **Depends**: M1-T01
- **Context**: M1 的核心 sweep。把三套 engine/Base/session 收敛成 `app/db.py` 一套(绑 app 库、开 WAL),所有模型挂到同一个 `Base`,依赖收敛为单一 `get_db`,所有路由改用它。**本任务必须原子落地**——删除旧模块会同时打断所有 importer,无法分多次保持绿色。Orchestrator 可按下方 Steps 的自然分段派给较强 implementer。
**Files**
- `modify app/db.py`(改写为统一数据层:`Base` + 绑 `app_database_url` 的 cached engine + WAL + `get_session_local` + `reset_db_caches` + `get_db_session`
- `delete app/auth_db.py`
- `delete app/poo_db.py`
- `delete app/models/base.py`
- `modify app/models/location.py``from app.db import Base`
- `modify app/models/poo.py`(改继承统一 `Base`import 改 `app.db`
- `modify app/models/auth.py``app/models/config.py``app/models/public_ip.py``AuthBase` → 统一 `Base`
- `modify app/models/__init__.py`(补导出 `PooRecord`,保证 `from app import models` 注册所有表到同一 metadata)
- `modify app/dependencies.py`(单一 `get_db`;删 `get_poo_db``get_app_settings`/`get_current_auth_session` 改用 `get_db`
- `modify app/api/routes/auth.py``pages.py``public_ip.py``ticktick.py``get_auth_db``get_db`
- `modify app/api/routes/location.py``poo.py``homeassistant.py`location/poo session 改用 `get_db`;删 `get_poo_db` 引用)
- `modify app/services/config_page.py``reset_auth_db_caches``reset_db_caches`
- `modify app/main.py``import app.auth_db as auth_db` → 统一层;`get_auth_session_local``get_session_local`
- `modify tests/conftest.py``tests/test_app.py``tests/test_auth.py``tests/test_ticktick.py``tests/test_homeassistant_inbound.py``tests/test_location.py``tests/test_poo.py`import sweep + 把 location/poo client 改成写 app 库的统一 session;移除对 `app.poo_db`/`app.db`(location) monkeypatch 的依赖)
**Steps**
1. 改写 `app/db.py``Base(DeclarativeBase)`;沿用 `auth_db.py` 的 cached-engine + reset 模式但绑 `app_database_url`;为 sqlite 连接注册 `PRAGMA journal_mode=WAL`(用 `event.listens_for(engine, "connect")` 或建连后执行)。导出 `get_engine`/`get_session_local`/`reset_db_caches`/`get_db_session`
2. 模型 sweep:所有 `from app.auth_db import AuthBase` / `from app.poo_db import PooBase` / `from app.db import Base` 统一成 `from app.db import Base`;类继承统一 `Base``app/models/__init__.py` 增加 `from app.models.poo import PooRecord` 并补进 `__all__`
3.`app/auth_db.py``app/poo_db.py``app/models/base.py`
4. 依赖 sweep`app/dependencies.py` 留单一 `get_db`yield 统一 session),删 `get_poo_db``get_app_settings``get_current_auth_session``Depends(get_auth_db)``Depends(get_db)`
5. 路由 sweep:所有 `Depends(get_auth_db)``Depends(get_poo_db)``Depends(get_db)` 统一为 `Depends(get_db)`(变量名 `auth_db_session`/`poo_db`/`db` 可保留,不强制改)。
6. `app/services/config_page.py``reset_auth_db_caches``reset_db_caches`
7. `app/main.py`:把 `_run_scheduled_public_ip_check` / `ensure_auth_db_ready` 里的 `auth_db.get_auth_session_local()` 换成统一 `get_session_local()`。(lifespan 里 location/poo 的 ready 检查留到 T04 删。)
8. 测试 sweep`reset_auth_db_caches``reset_db_caches`6 个文件);conftest 的 `location_client`/`poo_client` 改成"写入统一 app session 即可"的形式(不再 monkeypatch 已删除的 `app.poo_db`/location `app.db`);`test_homeassistant_inbound` 同理。
**Out of scope / 不要碰**
- 不删 `scripts/location_db_adopt.py` / `scripts/poo_db_adopt.py`,不改 lifespan 的 location/poo ready 调用(那是 T04,避免与本任务交叉冲突)。
- 不动 `config.py` 的字段(T05)。
- 不改业务逻辑(service 内部算法、HA 集成行为保持不变)。
**Acceptance criteria**
- [ ] `grep -rnE "auth_db|poo_db|PooBase|AuthBase|get_auth_db|get_poo_db|reset_auth_db_caches|app\.models\.base" app | grep -v __pycache__` 结果为空。
- [ ] `app/db.py` 的 engine 绑定 `app_database_url`sqlite 下 `PRAGMA journal_mode` 实测为 `wal`
- [ ] 所有模型 `Base.metadata.tables` 同时包含 auth/config/public_ip/location/poo_records 五类表。
- [ ] `pytest` 全绿(含 location/poo/homeassistant_inbound 测试在单库下通过)。
- [ ] `ruff check .` 无新增告警。
**Reviewer checklist**
- WAL 真的生效(实际连接 `PRAGMA journal_mode` 返回 `wal`),不是只写了注释。
- location/poo 的读写在单库下行为不变(端点仍返回 200、行落库)。
- 没有遗留指向已删模块的死 import;没有把业务逻辑顺手改了。
- `get_db` 现在产出的是 app 库 session(不是旧 location 库)。
---
### M1-T04 — 收敛启动链路:lifespan + run_migrations,删除 location/poo adopt 脚本
- **Status**: `todo`
- **Depends**: M1-T03
- **Context**: 单库后只需保证 app 库就绪;location/poo 的 adoption 链路整条退役。
**Files**
- `modify app/main.py``ensure_runtime_dirs` 只建 app 路径;删 `ensure_location_db_ready`/`ensure_poo_db_ready` 及其调用与 import
- `modify scripts/run_migrations.py`(只 `adopt_or_initialize_app_db`,返回 `{"app": ...}`
- `delete scripts/location_db_adopt.py`
- `delete scripts/poo_db_adopt.py`
- `delete alembic_location.ini``alembic_location/`(含 env.py、versions
- `delete alembic_poo.ini``alembic_poo/`
- `modify tests/test_deployment.py``run_all_migrations` 期望值改为单 `{"app": ...}`;删/改 legacy location/poo 迁移断言;保留"app DB 不存在则 fail-closed"用例)
- `modify tests/test_location.py``tests/test_poo.py`(删除针对已删 adopt 脚本的 adoption 测试;保留端点行为测试)
- `modify tests/conftest.py`(删 `_make_alembic_config`/`_make_poo_alembic_config`/`ready_location_database`/`ready_poo_database` 等已无意义的 fixture
**Steps**
1. `app/main.py`:移除 `from scripts.location_db_adopt ...` / `poo_db_adopt` import;删两个 `ensure_*_db_ready` 函数及 lifespan 中调用;`ensure_runtime_dirs` 只处理 `settings.app_sqlite_path`
2. `scripts/run_migrations.py``run_all_migrations` 只返回 app 一项。
3. 删除两套 adopt 脚本与两套 alembic 环境/ini。
4. 测试:把 `test_migration_runner_*` 改成单库口径;删掉引用已删脚本常量(`LOCATION_BASELINE_REVISION` 等)的用例。
**Out of scope / 不要碰**
- 不动 `scripts/app_db_adopt.py` 的核心逻辑(仅 T01 已更新其常量)。
- 不动数据搬迁脚本(T02)。
**Acceptance criteria**
- [ ] `grep -rnE "location_db_adopt|poo_db_adopt|alembic_location|alembic_poo" app scripts tests | grep -v __pycache__` 为空。
- [ ] 仓库不再有 `alembic_location*` / `alembic_poo*` 文件。
- [ ] `python -m scripts.run_migrations` 在全新临时 app 库上成功初始化(含 location/poo_records 表)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- lifespan 仍对 app 库 fail-closed(缺库时明确报错),未弱化启动安全。
- 没有残留对已删 alembic 环境的引用(包括 `.ini` 路径字符串)。
---
### M1-T05 — config 去除 location/poo URL 与路径,清理配置页与测试硬编码
- **Status**: `todo`
- **Depends**: M1-T03
- **Context**: 配置层只剩 `app_database_url`,运行时不再有 location/poo 库概念。
**Files**
- `modify app/config.py`(删 `location_database_url`/`poo_database_url` 字段与 `location_sqlite_path`/`poo_sqlite_path` computed 属性)
- `modify app/services/config_page.py`(配置页 sections 移除 `location_database_url`/`poo_database_url` 展示项)
- `modify .env.example`(移除两行 legacy DB URL;保留 `APP_DATABASE_URL`
- `modify tests/test_config.py`(删对两个 URL/路径的断言)
- `modify tests/test_public_ip.py``tests/test_smtp.py`(构造 `Settings` 时去掉 location/poo url 入参)
- `modify tests/conftest.py``test_database_urls` 不再 set `LOCATION_DATABASE_URL`/`POO_DATABASE_URL`
**Out of scope / 不要碰**
- 不动 `migrate_legacy_data.py`(它自带读旧库路径的能力,与 `Settings` 解耦)。
- 不改其它配置项(SMTP / TickTick / HA 等)。
**Acceptance criteria**
- [ ] `grep -rnE "location_database_url|poo_database_url|location_sqlite_path|poo_sqlite_path" app tests | grep -v __pycache__` 为空。
- [ ] 配置页渲染不再出现 location/poo DB URL 字段。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 没有别的代码还假设 `Settings` 上存在这两个属性(运行期不会 AttributeError)。
---
### M1-T06 — 移除 Grafana
- **Status**: `todo`
- **Depends**: none(可与 T01 并行)
- **Context**: 可视化将由 M2 的 React 承担;Grafana 直接删除,不再 re-point。
**Files**
- `modify docker-compose.yml`(删 `grafana` service 及其 `depends_on`/挂载;删顶层 `volumes.homeautomation_grafana_storage`
- `delete grafana/``provisioning/``dashboards/` 全部)
- `modify tests/test_deployment.py`(若有针对 grafana service 的断言则同步移除)
- `modify README.md`(删"Grafana Provisioning"整节——也可并入 T07,二选一,避免重复改同段)
**Out of scope / 不要碰**
- **不在脚本里删除** named volume `homeautomation_grafana_storage` 的实际数据卷——这是人工 ops 步骤(见 §6),compose 里移除声明即可。
- 不动 app/migration service。
**Acceptance criteria**
- [ ] `docker-compose.yml` 不再含 `grafana``homeautomation_grafana_storage`
- [ ] 仓库不再有 `grafana/` 目录。
- [ ] `docker compose config` 能成功解析(语法有效)。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 没有遗留对 `./grafana/...` 挂载路径的引用。
- 没有顺手删 `./data` 卷或改动 app service 端口/卷。
---
### M1-T07 — 文档与 OpenAPI 收尾
- **Status**: `todo`
- **Depends**: M1-T03, M1-T04, M1-T05
- **Context**: 让文档反映单库现实,并把"前后端不分离 / 三库不合并 / Grafana"约束在 architecture 文档中正式退役。
**Files**
- `modify README.md`(三库 → 单库;删 location/poo DB 初始化与 adopt 说明;更新"运行测试"段落使其与实际测试一致)
- `modify docs/architecture-overview.md`(退役"三库不合并"location/poo Alembic 链合并说明)
- `modify docs/roadmap.md`(勾掉 M1 范围项)
- `run python scripts/export_openapi.py` 并提交 `openapi/` 变更(location/poo 路由依赖在 T03 改过,schema 可能变化)
**Acceptance criteria**
- [ ] README / architecture 不再描述 location/poo 独立库与 adopt 脚本。
- [ ] `python scripts/export_openapi.py``git diff --exit-code openapi/` 无未提交差异。
- [ ] 校验闸门全绿。
**Reviewer checklist**
- 文档无残留的旧命令(`location_db_adopt.py` 等)。
- OpenAPI 已重导出且入库。
---
## 6. 人工操作 runbook(生产切换,不进自动化任务)
按数据安全红线,下列步骤由人执行,**不**写进 implementer 任务:
1. **备份**:停服前复制 `data/app.db``data/locationRecorder.db``data/pooRecorder.db` 到带时间戳的归档目录。
2. **演练**:把上述备份恢复到 scratch 目录,先在副本上跑完整流程(升级 + `migrate_legacy_data.py --dry-run` 再实跑),核对行数。
3. **部署新镜像**:新镜像的 migration job 会把 app 库升级到新 head,建出空的 `location` / `poo_records`
4. **搬数据**:在生产机运行 `python scripts/migrate_legacy_data.py`(指向归档前的旧库),核对对账输出。
5. **验证**app 起来后确认 location/poo 端点与历史查询正常、行数与旧库一致。
6. **(事后,确认无误再做)撤旧库**:归档旧 `.db` 文件、删除 `homeautomation_grafana_storage` 卷。**这一步人工、可回退地保留归档,永不在脚本中自动执行。**
## 7. 里程碑完成定义(Definition of Done
- 运行期只存在 `app.db` 一个库、一个 engine、一个 `Base`、一条 Alembic 链。
- `grep` 不到任何 `auth_db` / `poo_db` / location 独立库 / adopt 脚本 / grafana 的残留引用。
- 旧库历史数据已通过 `migrate_legacy_data.py` 搬入且对账通过。
- `pytest``ruff check .``export_openapi` 全绿且 `openapi/` 已入库。
- README / architecture / roadmap 反映单库现实。