Files
home-automation/docs/design/m1-db-consolidation.md
T
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

23 KiB
Raw Blame History

M1 — 单库化地基(DB Consolidation

阅读前提:先读 README.md(协作模型、任务卡格式、校验闸门、数据安全红线)。本文档只展开 M1 的现状、目标与原子任务。

1. 目标

把 location、poo 两个独立 SQLite 库合并进 app.db,收敛成单库 + 单 engine + 单 DeclarativeBase + 单 Alembic 链,清理项目早期散落的数据层代码,并移除 Grafana。历史数据零丢失。

2. 现状(实现者可据此工作,不必重新通读全仓库)

三套数据层(散落点)

  • app/db.pyBase + engine/SessionLocal/get_db_session —— 实际绑定 settings.location_database_url(即 location 库,命名有误导性)。
  • app/models/base.py:仅 from app.db import Base 转出。
  • app/poo_db.pyPooBase + poo_engine/PooSessionLocal/get_poo_db_session —— 绑定 poo_database_url
  • app/auth_db.pyAuthBase + 带 lru_cache_get_auth_engine / get_auth_session_local / reset_auth_db_caches / get_auth_db_session —— 绑定 app_database_url(真正的 app 库)。

模型与归属

  • app/models/auth.pyAuthUserAuthSessionAuthBase → app 库)
  • app/models/config.pyAppConfigEntryAuthBase → app 库)
  • app/models/public_ip.pyPublicIPStatePublicIPHistoryAuthBase → app 库)
  • app/models/location.pyLocationBase → location 库),表 locationPK(person,datetime)latitude/longitude NOT NULLaltitude nullable
  • app/models/poo.pyPooRecordPooBase → poo 库),表 poo_recordsPK(timestamp)status/latitude/longitude NOT NULL
  • app/models/__init__.py:导出除 PooRecord 外的模型(PooRecord 单独存在)

三条 Alembic 链

  • alembic_app.ini + alembic_app/env.pyAuthBase.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.pyscripts/poo_db_adopt.py(含 legacy 校验:EXPECTED_USER_VERSION、表结构断言)
  • scripts/run_migrations.py:依次调用三个 adopt 函数,返回 {"app","location","poo"}
  • app/main.py lifespanensure_runtime_dirsapp/location/poo 三路径)、ensure_auth_db_readyensure_location_db_readyensure_poo_db_ready,再起 APScheduler 每 4h 检查 public IP

依赖与路由

  • app/dependencies.pyget_auth_dbapp session)、get_dblocation session)、get_poo_dbpoo session)、get_app_settingsget_current_auth_sessionget_homeassistant_clientget_ticktick_client
  • app/api/routes/location.pyPOST /location/record,依赖 get_db无鉴权
  • app/api/routes/poo.pyPOST /poo/recordGET /poo/latest,依赖 get_poo_db无鉴权
  • app/api/routes/homeassistant.py:同时用 get_dblocation)和 get_poo_db

config

  • app/config.pyapp_database_url / location_database_url / poo_database_url 三字段 + computed app_sqlite_path / location_sqlite_path / poo_sqlite_path
  • app/services/config_page.pybuild_runtime_settings 用到 reset_auth_db_caches;配置页 sections 暴露 location_database_url / poo_database_url(约 263264 行)

测试耦合点(M1 必然要改)

  • tests/conftest.pytest_database_urls 设三套环境变量;ready_location_database / ready_poo_database / auth_database / location_clientmonkeypatch app_db.engine/SessionLocal/ poo_clientmonkeypatch 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.pymonkeypatch app.poo_db
  • tests/test_config.py / tests/test_public_ip.py / tests/test_smtp.py:硬编码三套 URL / 路径
  • reset_auth_db_cachesconftesttest_apptest_authtest_deploymenttest_ticktick 引用

3. 目标架构(M1 完成态)

单数据层 app/db.py

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.pyapp/poo_db.pyapp/models/base.py
  • 依赖收敛为单一 get_dbapp 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

T01T06 无前置可先开;T02 依赖 T01T03 依赖 T01T04/T05 依赖 T03T07 最后。


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. 新 revisionrevision = "20260611_06_merge_location_poo_tables"down_revision = "20260429_05_public_ip_monitor"
  2. upgrade()op.create_table 手写建 locationpoo_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.pyAPP_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_masterlocationpoo_records、且与旧 baseline 表结构一致(PRAGMA table_info 对齐)。
  • downgrade -1 能干净回滚这两张表。
  • APP_BASELINE_REVISION == "20260611_06_merge_location_poo_tables"
  • 校验闸门全绿(pytesttest_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) -> dictCLI 默认从 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.pyfrom app.db import Base
  • modify app/models/poo.py(改继承统一 Baseimport 改 app.db
  • modify app/models/auth.pyapp/models/config.pyapp/models/public_ip.pyAuthBase → 统一 Base
  • modify app/models/__init__.py(补导出 PooRecord,保证 from app import models 注册所有表到同一 metadata
  • modify app/dependencies.py(单一 get_db;删 get_poo_dbget_app_settings/get_current_auth_session 改用 get_db
  • modify app/api/routes/auth.pypages.pypublic_ip.pyticktick.pyget_auth_dbget_db
  • modify app/api/routes/location.pypoo.pyhomeassistant.pylocation/poo session 改用 get_db;删 get_poo_db 引用)
  • modify app/services/config_page.pyreset_auth_db_cachesreset_db_caches
  • modify app/main.pyimport app.auth_db as auth_db → 统一层;get_auth_session_localget_session_local
  • modify tests/conftest.pytests/test_app.pytests/test_auth.pytests/test_ticktick.pytests/test_homeassistant_inbound.pytests/test_location.pytests/test_poo.pyimport sweep + 把 location/poo client 改成写 app 库的统一 session;移除对 app.poo_db/app.db(location) monkeypatch 的依赖)

Steps

  1. 改写 app/db.pyBase(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;类继承统一 Baseapp/models/__init__.py 增加 from app.models.poo import PooRecord 并补进 __all__
  3. app/auth_db.pyapp/poo_db.pyapp/models/base.py
  4. 依赖 sweepapp/dependencies.py 留单一 get_dbyield 统一 session),删 get_poo_dbget_app_settingsget_current_auth_sessionDepends(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.pyreset_auth_db_cachesreset_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. 测试 sweepreset_auth_db_cachesreset_db_caches6 个文件);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_urlsqlite 下 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.pyensure_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.inialembic_location/(含 env.py、versions
  • delete alembic_poo.inialembic_poo/
  • modify tests/test_deployment.pyrun_all_migrations 期望值改为单 {"app": ...};删/改 legacy location/poo 迁移断言;保留"app DB 不存在则 fail-closed"用例)
  • modify tests/test_location.pytests/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.pyrun_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.pytests/test_smtp.py(构造 Settings 时去掉 location/poo url 入参)
  • modify tests/conftest.pytest_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 不再含 grafanahomeautomation_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.pygit diff --exit-code openapi/ 无未提交差异。
  • 校验闸门全绿。

Reviewer checklist

  • 文档无残留的旧命令(location_db_adopt.py 等)。
  • OpenAPI 已重导出且入库。

6. 人工操作 runbook(生产切换,不进自动化任务)

按数据安全红线,下列步骤由人执行,写进 implementer 任务:

  1. 备份:停服前复制 data/app.dbdata/locationRecorder.dbdata/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 搬入且对账通过。
  • pytestruff check .export_openapi 全绿且 openapi/ 已入库。
  • README / architecture / roadmap 反映单库现实。