- 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/
23 KiB
M1 — 单库化地基(DB Consolidation)
阅读前提:先读
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/longitudeNOT NULL,altitudenullableapp/models/poo.py:PooRecord(PooBase→ poo 库),表poo_records,PK(timestamp),status/latitude/longitudeNOT NULLapp/models/__init__.py:导出除PooRecord外的模型(PooRecord单独存在)
三条 Alembic 链
alembic_app.ini+alembic_app/(env.py用AuthBase.metadata),head =20260429_05_public_ip_monitoralembic_location.ini+alembic_location/,head =20260419_01_location_baselinealembic_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.pylifespan: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_clientapp/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三字段 + computedapp_sqlite_path/location_sqlite_path/poo_sqlite_pathapp/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(monkeypatchapp_db.engine/SessionLocal)/poo_client(monkeypatchpoo_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_adopttests/test_homeassistant_inbound.py:monkeypatchapp.poo_dbtests/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
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.pymodify scripts/app_db_adopt.py(更新APP_BASELINE_REVISION)
Steps
- 新 revision:
revision = "20260611_06_merge_location_poo_tables",down_revision = "20260429_05_public_ip_monitor"。 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))。downgrade():op.drop_table("poo_records")+op.drop_table("location")。- 把
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.pycreate tests/test_migrate_legacy_data.py
Steps
- 入口
migrate_legacy_data(app_url, location_url, poo_url, *, dry_run=False) -> dict,CLI 默认从 env 读三个 url(即便 location/poo url 已从Settings移除,本脚本可直接读环境变量或接受--location-db/--poo-db参数,保持自包含)。 - 对每个旧库:若文件不存在 → 该表
skipped(不报错,保证 CI / 全新部署可安全 no-op)。 - 拷贝用 SQLite
ATTACH DATABASE '<old>' AS legacy+INSERT OR IGNORE INTO main.<table> SELECT <显式列> FROM legacy.<table>(显式列名,禁用SELECT *)。INSERT OR IGNORE保证幂等(PK 冲突跳过)。 - 搬完对账:对每张表比对
源行数与目标行数中来自源的部分;目标行数 < 源行数则raise并以非零码退出。 dry_run模式只读统计、不写入。- 打印每表结果:
{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.pydelete app/poo_db.pydelete app/models/base.pymodify 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
- 改写
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。 - 模型 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__。 - 删
app/auth_db.py、app/poo_db.py、app/models/base.py。 - 依赖 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)。 - 路由 sweep:所有
Depends(get_auth_db)、Depends(get_poo_db)、Depends(get_db)统一为Depends(get_db)(变量名auth_db_session/poo_db/db可保留,不强制改)。 app/services/config_page.py:reset_auth_db_caches→reset_db_caches。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 删。)- 测试 sweep:
reset_auth_db_caches→reset_db_caches(6 个文件);conftest 的location_client/poo_client改成"写入统一 app session 即可"的形式(不再 monkeypatch 已删除的app.poo_db/locationapp.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.pydelete scripts/poo_db_adopt.pydelete 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
app/main.py:移除from scripts.location_db_adopt .../poo_db_adoptimport;删两个ensure_*_db_ready函数及 lifespan 中调用;ensure_runtime_dirs只处理settings.app_sqlite_path。scripts/run_migrations.py:run_all_migrations只返回 app 一项。- 删除两套 adopt 脚本与两套 alembic 环境/ini。
- 测试:把
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_pathcomputed 属性)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不再 setLOCATION_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(删grafanaservice 及其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 任务:
- 备份:停服前复制
data/app.db、data/locationRecorder.db、data/pooRecorder.db到带时间戳的归档目录。 - 演练:把上述备份恢复到 scratch 目录,先在副本上跑完整流程(升级 +
migrate_legacy_data.py --dry-run再实跑),核对行数。 - 部署新镜像:新镜像的 migration job 会把 app 库升级到新 head,建出空的
location/poo_records。 - 搬数据:在生产机运行
python scripts/migrate_legacy_data.py(指向归档前的旧库),核对对账输出。 - 验证:app 起来后确认 location/poo 端点与历史查询正常、行数与旧库一致。
- (事后,确认无误再做)撤旧库:归档旧
.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 反映单库现实。