# 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`（约 263–264 行）

**测试耦合点（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；建连时启用 WAL（PRAGMA 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 反映单库现实。
