docs: add next-phase roadmap, milestone design docs, and CLAUDE.md
pytest / test (push) Successful in 54s

- 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/
This commit is contained in:
2026-06-12 15:37:17 +02:00
parent 636bb2b80b
commit b359bbe3bf
7 changed files with 1102 additions and 0 deletions
+1
View File
@@ -5,3 +5,4 @@
__pycache__/
*.pyc
data/
review-notes/
+137
View File
@@ -0,0 +1,137 @@
# CLAUDE.md — Home Automation Backend
本文件每次会话自动加载。它定义本项目的**工作流程、文档位置、commit 规范**。请在动手前先读完。
## 项目速览
- 个人用 home-automation 后端:**FastAPI + SQLite + SQLAlchemy + Alembic**,服务端模板(JinjaM2 将换成 React SPA)。
- 单 admin 鉴权(Argon2 + server-side session cookie),runtime config 落 `app_config` 表。
- 模块:public IPv4 monitor、SMTP 通知、location recorder、poo recorder、Home Assistant in/out、TickTick OAuth。
- 已发布 `v1.0.3`。下一阶段方向:**M1 单库化 → M2 React 前端 → M3 token/移动端(远期,M2 后再说)**。
- **当前现实**:在 M1 完成前仍是**三个独立 SQLite 库**app / location / poo),三套 DeclarativeBase、三条 Alembic 链。不要假设已经单库——以代码现状为准。
- 明确不做:Notion 模块。
## 文档地图与「开工前必读」
文档都在 `docs/`
| 路径 | 作用 |
| --- | --- |
| `docs/roadmap.md` | 全局规划与里程碑总览 |
| `docs/design/README.md` | **协作契约**:任务卡格式、原子任务定义、校验闸门、数据安全红线 |
| `docs/design/m1-db-consolidation.md` | M1 原子任务(含真实代码现状盘点 + 人工 runbook) |
| `docs/design/m2-frontend-v2.md` | M2 原子任务 + API 契约 + 前端校验闸门 |
| `docs/design/m3-token-mobile.md` | M3(远期,暂缓) |
| `docs/*.md`auth / public-ip-monitor / location-recorder …) | 各模块说明,按需读 |
**开工时读取顺序**
1. `docs/design/README.md`(每轮都读,它是流程与验收的共同契约)。
2. 本轮对应的 milestone 文档(如 `docs/design/m1-db-consolidation.md`),定位要做的任务卡。
3. 任务卡 `Files` 列出的源文件 + 该模块的 `docs/*.md`(按需)。
4. `docs/roadmap.md` 仅在需要全局视角时读。
## 工作流程
### 实现模式(由用户的提示词决定)
- **默认逐步**:给一个 milestone 文档,按其中原子任务**一步一步**实现。
- **(a) 只实现一步**:用户说"只实现一步 / 这一个任务"时,**只做那一个任务卡**,跑完校验闸门后停下,等用户确认,不要顺手往下做。
- **(b) 完成整个 milestone**:仅当用户在提示词里**显式要求启用 sub-agent 并指定模型**时,才用指定模型起 implementer sub-agent,按任务依赖顺序跑完整条链。
- **Sub-agent 纪律**:只在用户显式要求时才 spawn sub-agent;单步/小改动在主线内联完成。起 sub-agent 时用用户**指定的模型**Agent 工具的 `model` 覆盖)。
### 角色(Orchestrator → Implementer → Reviewer
- 我(主线)= **Orchestrator**:挑依赖已满足的下一个任务、派发、转述结果、维护任务 `Status`
- **Implementer**(便宜模型,用户指定):一次一个任务,严格按任务卡,不扩范围。
- **Reviewer**(强模型,用户指定):实现完成后起 Reviewer sub-agent,按任务卡 `Acceptance criteria` + `Reviewer checklist` 复核、**独立重跑校验闸门**,驱动 implementer 返工直到本轮 PASS。
### 校验闸门(每个任务结束都要全绿)
根目录、激活 `.venv` 后:
```bash
pytest # 权威闸门(CI 跑的就是它)
ruff check . # line-length=100
python scripts/export_openapi.py && git diff --exit-code openapi/ # 改了路由/schema 才需要,且产物须入库
```
前端任务(M2)在 `frontend/` 下另跑 `npm run lint && npm run typecheck && npm run test && npm run build`(详见 m2 文档 §8)。
**不过闸门就不算完成**,不得跳过、不得留红给下一轮。
## 每轮简报(`review-notes/`
每轮工作都要在 `review-notes/` 下产出**中文简报**。该目录**已在 `.gitignore` 忽略**,纯本地、不入库——它是 agent 之间和与人之间的交接载体,不是仓库产物。
- **实现 / 返工简报**:每轮实现完成后(无论首次实现还是返工),写一份。文件名建议 `<task-id>-impl-<n>.md` / `<task-id>-rework-<n>.md`(如 `M1-T03-impl-1.md``M1-T03-rework-1.md`)。至少包含:
1. **本轮修改的具体内容**(改了哪些文件、做了什么、为什么)。
2. **自动化测试结果**`pytest` / `ruff` / 前端闸门的实际输出或结论,通过/失败逐项写清)。
3. **若需人工 walkthrough**:写明具体步骤(怎么启动、点哪里、预期看到什么);若无需人工验证,明确写"无需人工 walkthrough"。
- **review 简报**:每轮 review 后写一份,文件名建议 `<task-id>-review-<n>.md`(如 `M1-T03-review-1.md`)。至少包含:评审结论(`PASS` 或带编号的返工清单)、对照任务卡 `Acceptance criteria` + `Reviewer checklist` 的逐条核对、reviewer 独立重跑校验闸门的结果。
**用途**:① reviewer 审核时参考对应的实现简报;② implementer 返工时参考对应的 review 简报;③ 人类(用户)通读这些简报确认有无问题。简报之间用文件名里的 `<task-id>` 与轮次 `<n>` 对应起来。
### Orchestrator 派发契约(让简报真正被读到)
**关键**:sub-agent 冷启动、不继承主线上下文,**不会因为本文件提到简报就自动去读**对应文件。简报能流转,靠的是 orchestrator(主线)在**每次 spawn 时把路径显式写进 prompt**,而不是被动约定。所以派发时必须做到:
- **显式告诉它「先读哪个简报」**:
- 派 implementer 做**首次实现** → 传任务卡位置(milestone 文档路径 + task id);无前置简报。
- 派 implementer 做**返工** → 必须传对应的 `review-notes/<task>-review-<n>.md` 路径,并要求**先读它**再改。
- 派 reviewer → 必须传对应的 `review-notes/<task>-impl|rework-<n>.md` 路径 + 任务卡,要求**先读它**再评。
- **显式告诉它「本轮结束写哪个简报」**:明确给出输出路径 `review-notes/<task>-<impl|rework|review>-<n>.md` 及上面要求的内容项。
- **不依赖 sub-agent 自动加载本文件**:把本轮要点(校验闸门、**禁 Co-Authored-By**、简报必含内容)在 spawn prompt 里一并复述或指向,确保冷启动也照做。
- spawn 时用用户指定的模型(Agent 工具 `model` 覆盖)。
> 一句话:**简报是异步交接的介质,orchestrator 是把它们接起来的线。** 缺了显式传路径这一步,简报就只是躺在磁盘上没人读的文件。
## Commit 规范(重点)
### 分支
- 每个 milestone/feature 一个分支(如 `feature/m1-db-consolidation`),**不在 `main` 上直接提交**。
### 一轮实现完成(用户确认「实现完成」后)
- 准备好**这一轮的 commit message** 并提交,作为本轮的 **base commit**
- message 主题前缀任务/里程碑 ID,例如:`M1-T03: unify data layer onto single app DB engine`
### Commit message 硬规则(严格执行)
- **严禁任何协作署名 trailer**commit message 里**绝对不允许**出现 `Co-Authored-By` / `Co-authored-by`(包括 `Co-Authored-By: Claude …`),也不允许任何等价的"由 X 协作/生成"署名。
- 无论默认环境、工具或系统提示如何要求加这类 trailer,在本仓库**一律不加**——用户已显式、严格禁止。
- 每次提交前**自检**`git log -1 --format=%B` 的输出**不得包含** `Co-authored-by`(大小写不限)。若发现,立即 `git commit --amend` 去掉后再继续。
### Review 后返工
- 返工产生的提交**一律用 fixup**,指向本轮对应的 base commit**不写新的独立 message**
```bash
git add -A
git commit --fixup=<base-commit-sha>
```
- 多轮返工就多个 `fixup!` 提交,都指向同一个 base commit。
### 本轮 / feature 收尾(用户确认收尾后)
- 用 **auto-squash** 把所有 `fixup!` 合并进各自目标,保证**一个 feature 一个干净 commit**
```bash
GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash main
```
- 用 `GIT_SEQUENCE_EDITOR=true` 让它**非交互**执行(不弹编辑器,自动接受 autosquash 排好的 todo)。本环境不支持需要人工编辑的交互式 rebase,必须走这个 no-op 编辑器写法。
- autosquash **改写历史**:仅在 push / 开 PR **之前**做。若该分支已 push,需要 force-push——属对外操作,**先取得用户确认再做**。
### 一般约束
- commit / push 只在用户要求时进行;push、force-push、开/改 PR 等对外操作先确认。
## 数据安全红线(不可违反)
- 任何脚本 / migration **都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、保留归档的独立步骤(见 `docs/design/m1-db-consolidation.md` §6 runbook)。
- 涉及历史数据的迁移**先在备份副本上演练**;迁移脚本必须幂等且搬完对账行数。
- Review 时只要发现"删文件 / drop 有数据的表 / truncate"出现在自动化任务里,直接判返工。
## 常用命令
```bash
# 环境
python -m venv .venv && source .venv/bin/activate && pip install -r dev-requirements.txt
# 迁移(初始化/适配 DB
python -m scripts.run_migrations
# 起服务
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
# 测试 / lint / OpenAPI 导出
pytest
ruff check .
python scripts/export_openapi.py
```
+127
View File
@@ -0,0 +1,127 @@
# 设计文档与多模型协作约定
本目录把 `docs/roadmap.md` 里的三个里程碑展开成**可被 coding agent 流水线执行**的详细设计文档。
- [`m1-db-consolidation.md`](./m1-db-consolidation.md) — 单库化地基
- [`m2-frontend-v2.md`](./m2-frontend-v2.md) — React SPA 前端 v2
- [`m3-token-mobile.md`](./m3-token-mobile.md) — token 鉴权与移动端(远期)
本文件定义**所有任务共用的格式与协作规则**,三个里程碑文档不再重复这些约定。
---
## 1. 协作模型:Orchestrator → Implementer → Reviewer
设计目标是让三个不同档位的模型分工协作:
| 角色 | 模型档位 | 职责 |
| --- | --- | --- |
| **Orchestrator(编排者)** | 强模型 | 读里程碑文档,挑出依赖已满足的下一个原子任务,派发给 implementer;收到 reviewer 的 PASS 后推进下一个任务 |
| **Implementer(实现者)** | 便宜模型 | 一次只做**一个**原子任务,严格按任务卡执行,跑完本地校验后回报 |
| **Reviewer(评审者)** | 强模型 | 对照验收标准 + Reviewer checklist 独立复核、独立跑校验闸门,返回 `PASS` 或一份编号返工清单 |
### 循环
```
Orchestrator 选任务 T(其 Depends 全部为 done
Implementer 实现 T ──► 跑校验闸门 ──► 回报 diff + 校验输出
Reviewer 复核 T
├── PASS ─────────► Orchestrator 标记 T 为 done,进入下一个任务
└── REWORK[1..n] ─► 退回 Implementer,按编号逐条修,直到 PASS
```
### 角色边界(重要)
- **Implementer 不得扩大范围**:只能改任务卡 `Files` 里列出的文件;超出范围的问题要在回报里以 `OUT-OF-SCOPE:` 标注,交给 Orchestrator 决定是否新开任务,而不是顺手改掉。
- **Reviewer 必须独立重跑校验闸门**,不能只信 implementer 的回报。
- **Reviewer 的返工清单必须可执行、带编号**(`REWORK 1: ...`),不写主观感受。
- 一个任务**不通过校验闸门就不算完成**,Orchestrator 不得跳过。
---
## 2. 原子任务的定义
一个"原子任务"必须同时满足:
1. **单一关注点**:一个任务只解决一件事(一次 schema 变更、一个端点、一个模块迁移……)。
2. **PR 大小**:理想 diff < ~200 行(结构性 sweep 任务可放宽,但应在卡上标 `[structural]` 并优先派给较强 implementer)。
3. **边界处可绿**:任务完成时,整个仓库通过校验闸门(见下)。**一个任务"拥有"它所改代码对应的测试**——如果改动会让某些现有测试失败,修这些测试就属于这个任务的范围,不允许留红给下一个任务。
4. **可独立验收**:验收标准是客观、可机械检查的断言,不依赖人的主观判断。
5. **依赖显式**:通过 `Depends` 字段声明前置任务。没有声明依赖的任务,Orchestrator 可并行派发。
---
## 3. 任务卡格式
每个任务在里程碑文档中以如下结构出现。Implementer 和 Reviewer 都只需要任务卡 + 本约定文件,**不需要读其它任务**即可工作。
```markdown
### M{n}-T{nn} — <标题> [structural?]
- **Status**: `todo` | `in-progress` | `in-review` | `done`
- **Depends**: M{n}-T{nn}, …(或 `none`
- **Context**: 1–2 句,为什么要做这个、它在里程碑里的位置。
**Files**(精确到路径,标注动作)
- `create path/to/new_file.py`
- `modify path/to/existing.py`
- `delete path/to/old.py`
**Steps**(便宜模型可直接照做的有序步骤)
1.
2.
**Out of scope / 不要碰**
- …(明确列出容易被误改的相邻区域,约束便宜模型漂移)
**Acceptance criteria**(客观、可勾选;Reviewer 逐条核)
- [ ]
- [ ] 校验闸门全绿(见 §4
**Reviewer checklist**(除验收标准外,强模型重点看的点)
-
```
---
## 4. 校验闸门(每个任务结束都要全绿)
在仓库根目录、激活 `.venv` 后执行:
```bash
# 1) 单元 / 集成测试(CI 同款,权威闸门)
pytest
# 2) Lintpyproject 已配置 ruffline-length=100
ruff check .
# 3) 若本任务改动了任何 HTTP 路由 / schema:重导出 OpenAPI 并确认已提交
python scripts/export_openapi.py
git diff --exit-code openapi/ # 必须无未提交差异
```
- `pytest` 是**权威闸门**`.github/workflows/pytest.yml` 跑的就是它,任何任务都不得让它变红。
- 改了路由 / Pydantic schema 的任务,`openapi/openapi.json``openapi/openapi.yaml` 必须在同一任务里重新生成并提交(`openapi/` 纳入版本控制)。
- 前端(M2/M3)相关任务的前端侧闸门(lint / typecheck / build)在对应里程碑文档里单独定义。
---
## 5. 提交与集成约定
- 每个任务一个 commit,message 前缀任务 ID,例如:`M1-T03: unify data layer onto single app DB engine`
- 一个里程碑在一个 feature 分支上推进(如 `feature/m1-db-consolidation`),按任务依赖顺序合并。
- 任务卡里的 `Status` 字段由 Orchestrator 维护,作为流水线的单一进度源。
- 涉及**不可逆 / 数据破坏**的步骤(删旧 DB 文件、删 Grafana volume 等)一律不进自动化任务,只在文档里标为人工步骤(见 M1 的"人工操作"小节)。
---
## 6. 数据安全红线(贯穿所有里程碑,不可违反)
1. **任何脚本 / migration 都不得删除或覆盖用户数据文件**(旧 `.db`、备份、volume)。删除只能是人工、事后、确认无误的独立步骤。
2. 涉及历史数据的迁移**先在备份副本上演练**,再对真实库执行。
3. 数据迁移脚本必须**幂等**且**搬完对账行数**,对不上立即中止并非零退出。
4. 破坏性 Reviewer 一票否决:只要任务里出现"删文件 / drop 有数据的表 / truncate"Reviewer 直接 REWORK,要求改为人工步骤。
+348
View File
@@ -0,0 +1,348 @@
# 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 反映单库现实。
+232
View File
@@ -0,0 +1,232 @@
# M2 — 前端 v2React SPA
> 阅读前提:先读 [`README.md`](./README.md)。M2 依赖 M1 完成(单库 + 干净的数据层 + API 建立在合并后的 schema 上)。
## 1. 目标
**React SPA** 取代现有 Jinja 页面,由 FastAPI **同源**托管(同一容器、同一 origin)。一步合并 roadmap 的"前端重写"与"前端做厚":配置界面 + 数据可视化(热力图 / 地图,接管 Grafana)+ 记录的按需展示与小幅增删改。
> **元目标(agentic 实验)**:这是用 agent 写 React 的试水,全程尽量不读代码。因此本里程碑**强约束 OpenAPI → 类型化 TS client 作为契约护栏**:后端 API 先稳,前端永远对着强类型契约写,便宜模型不易跑偏,reviewer 也有客观依据。
## 2. 现状(M1 完成后)
- 页面仍是服务端 Jinja`app/api/routes/pages.py``GET/POST /config``/``/admin``POST /config/smtp/test`+ `app/templates/``base/config/home/login.html``styles.css`)。
- 鉴权:`get_current_auth_session`(读 `auth_session_cookie_name` cookie),server-side session + 每会话 `csrf_token` 内嵌在表单。
- `app/main.py``app.mount("/static", StaticFiles(...))`
- 配置读写逻辑在 `app/services/config_page.py``build_config_sections` / `save_config_updates` / `build_runtime_settings`)。
- 业务数据:单库中的 `location``poo_records``public_ip_state``public_ip_history`
## 3. 目标架构
### 3.1 后端:JSON API + SPA 托管
- 所有数据交互走 **JSON API**,统一前缀 `/api`(SPA 是客户端渲染,必须有 API——这与"同源/同容器"无关)。
- FastAPI 既挂 `/api/*`,又挂 SPA 静态产物,并对非 `/api`、非静态资源的路径**回退到 `index.html`**(支持前端路由 deep-link)。
- Jinja 页面在 SPA 达到功能对齐后移除。
### 3.2 鉴权:复用 session cookie + SPA 版 CSRF
- 继续用现有 **HttpOnly session cookie**(同源自动携带),M2 **不引入 token**token 属 M3)。
- CSRF:新增 `GET /api/session` 返回当前用户 + 该会话的 `csrf_token`SPA 在所有写请求(POST/PUT/PATCH/DELETE)放 `X-CSRF-Token` header,后端校验其与 session 内 `csrf_token` 一致。等价于把现有表单 CSRF 平移到 header。
- 浏览器面向的所有新端点一律 session 保护;**裸 ingestion 端点(设备调用的 `POST /location/record``POST /poo/record`)维持现状到 M3**。
### 3.3 前端工程
- `frontend/`**Vite + React + TypeScript**。
- API client:由后端 `openapi/openapi.json` **自动生成** TS 类型与请求函数(如 `openapi-typescript` + 轻量 fetch 封装,或同类工具)。生成物入库或在 build 时生成(见 T06 决策)。
- 可视化:地图 + 热力图(location 轨迹 / poo 点位)。建议 **MapLibre GL 或 Leaflet + heatmap 插件**(最终选型见 §5 决策)。
- 状态/数据请求:轻量即可(如 TanStack Query),不引入重型框架。
### 3.4 构建与部署
- 多阶段 `Dockerfile`node 阶段 `npm ci && npm run build` → 把 `frontend/dist` 拷进 python 镜像的静态目录;运行镜像不带 node。
- compose 仍是单 app 容器(同源)。
## 4. API 契约(M2 要落地的端点)
> 全部 `/api` 前缀、session 保护、JSON 进出。具体 schema 在各任务里用 Pydantic 定义,并经 `export_openapi.py` 固化。
| 分组 | 端点 | 用途 |
| --- | --- | --- |
| 会话 | `GET /api/session` | 返回当前用户 + csrf_token;未登录 401 |
| 会话 | `POST /api/auth/login` | 账号密码登录,下发 session cookie |
| 会话 | `POST /api/auth/logout` | 注销 |
| 会话 | `POST /api/auth/password` | 改密(沿用现有强制改密语义)|
| 配置 | `GET /api/config` | 返回配置 sectionssecret 不回显)|
| 配置 | `PUT /api/config` | 保存配置(留空保留旧 secret 语义不变)|
| 配置 | `POST /api/config/smtp/test` | 触发测试发信 |
| 数据 | `GET /api/locations` | location 记录查询(时间范围/分页,供地图/热力图)|
| 数据 | `GET /api/poo` | poo 记录列表(分页)|
| 数据 | `GET /api/public-ip` | 当前状态 + 变化历史 |
| CRUD | `PATCH /api/locations/{person}/{datetime}` | 修正单条 location |
| CRUD | `DELETE /api/locations/{person}/{datetime}` | 删除单条 location |
| CRUD | `PATCH /api/poo/{timestamp}` | 修正单条 poo |
| CRUD | `DELETE /api/poo/{timestamp}` | 删除单条 poo |
> 记录 CRUD 依赖现有 PK 作行标识(location PK=`person+datetime`poo PK=`timestamp`)。路径参数需对 `datetime`/`timestamp` 做 URL 编码处理。
## 5. 需先拍板的决策(Orchestrator 在派 T06 前确认)
1. **地图/热力图库**MapLibre GL(矢量、现代)vs Leaflet(简单、生态大)。推荐 Leaflet + `leaflet.heat`(试水门槛低)。
2. **OpenAPI client 生成物**:入库(确定性、便于 review)vs build 时生成(仓库干净)。推荐**入库**,并加一个 `npm run codegen` + CI 校验"生成物与 openapi 同步"。
3. **CSRF 落地**header `X-CSRF-Token` + `GET /api/session` 下发(推荐)vs 双提交 cookie。
4. **是否保留少量 Jinja**:建议 SPA 对齐后**全量移除** `templates/`,只留 SPA。
> 这些可用 1 个轻量"决策任务"或直接由 Orchestrator 在本节记录选择,再开 T06。
## 6. 任务依赖图
```
后端 API(可与前端 scaffold 并行)
M2-T01 config API
M2-T02 session/auth API ─┐
M2-T03 data read API ├─► 都产出 OpenAPI 契约
M2-T04 record CRUD API │
M2-T05 smtp/action API ─┘
│ (openapi 稳定后)
M2-T06 前端 scaffold + codegen ──► M2-T07 auth UI
├─► M2-T08 config UI
├─► M2-T09 可视化 UI
└─► M2-T10 records 管理 UI
M2-T11 FastAPI 托管 SPA + 移除 Jinja(依赖 T07T10 达到对齐)
M2-T12 多阶段 Dockerfile + CI/compose
M2-T13 文档 + OpenAPI 收尾
```
---
## 7. 原子任务(任务卡)
> 后端任务沿用 M1 的校验闸门(`pytest` / `ruff` / `export_openapi`)。前端任务的闸门见 §8。
### M2-T01 — config JSON API
- **Status**: `todo` · **Depends**: noneM1 完成后)
- **Context**: 把 `config_page` 的读写能力暴露成 JSON,复用现有 service,不重写业务逻辑。
- **Files**: `create app/api/routes/api/config.py``create app/schemas/config.py``modify app/main.py`(注册路由);`create tests/test_api_config.py`
- **Steps**: 用 `build_config_sections`/`save_config_updates` 包出 `GET/PUT /api/config`session 保护;secret 不回显、留空保留旧值语义照搬。
- **Acceptance**:
- [ ] 未登录访问 `GET /api/config` 返回 401。
- [ ] 登录后 `GET` 返回 sectionssecret 字段被遮罩。
- [ ] `PUT` 留空 secret 时保留旧值;非法值返回 4xx 且不写库。
- [ ] 校验闸门全绿(含 `openapi/` 重导出入库)。
- **Reviewer**: 复用了 service 而非复制逻辑;CSRF 校验存在;secret 不泄漏到响应或 OpenAPI 示例。
### M2-T02 — session / auth JSON API
- **Status**: `todo` · **Depends**: none
- **Context**: 给 SPA 提供登录/注销/会话探测 + CSRF 下发。
- **Files**: `create app/api/routes/api/session.py``app/schemas/session.py``modify app/main.py``create tests/test_api_session.py`
- **Steps**: `GET /api/session`401 或 user+csrf)、`POST /api/auth/login``POST /api/auth/logout``POST /api/auth/password`,复用 `app/services/auth.py`
- **Acceptance**:
- [ ] 正确账号密码登录后置下 HttpOnly session cookie`GET /api/session` 返回 user + csrf_token。
- [ ] 错误凭据 401,不下发 cookie。
- [ ] 写端点缺 `X-CSRF-Token` 或不匹配 → 403。
- [ ] 强制改密语义与现有一致。
- [ ] 校验闸门全绿。
- **Reviewer**: cookie 仍 HttpOnly、`Secure` 跟随 `app_env``SameSite=Lax`;密码仍 Argon2,不明文。
### M2-T03 — 数据读取 APIlocations / poo / public-ip
- **Status**: `todo` · **Depends**: none
- **Files**: `create app/api/routes/api/data.py``app/schemas/data.py``modify app/main.py``create tests/test_api_data.py`
- **Steps**: `GET /api/locations`(时间范围 + 分页)、`GET /api/poo`(分页)、`GET /api/public-ip`state + history);session 保护;查询参数有上限防全表导出。
- **Acceptance**:
- [ ] 分页/时间范围参数生效且有上限;越权未登录 401。
- [ ] 返回 schema 经 OpenAPI 固化。
- [ ] 校验闸门全绿。
- **Reviewer**: 查询走索引/PK,无 N+1;时间过滤边界正确。
### M2-T04 — 记录 CRUD API(修正 / 删除)
- **Status**: `todo` · **Depends**: M2-T03
- **Files**: `modify app/api/routes/api/data.py``app/services/location.py``app/services/poo.py``create tests/test_api_record_crud.py`
- **Steps**: `PATCH`/`DELETE` locationPK person+datetime)与 pooPK timestamp);session + CSRF 保护;PK 路径参数 URL 解码;删除是**硬删单行**(不是清表)。
- **Acceptance**:
- [ ] PATCH 改单行字段、DELETE 删单行,行数变化精确为 1。
- [ ] 不存在的 PK → 404。
- [ ] 缺 CSRF → 403。
- [ ] 没有任何"批量删/清表"路径。
- [ ] 校验闸门全绿。
- **Reviewer**: 删除限定单 PK;编辑校验输入;ingestion 裸端点未被顺手加保护或改动。
### M2-T05 — SMTP 测试 / 动作类 JSON API
- **Status**: `todo` · **Depends**: M2-T01
- **Files**: `modify app/api/routes/api/config.py``modify tests/test_api_config.py`
- **Steps**: `POST /api/config/smtp/test` 复用 `send_smtp_test_email`,返回结构化结果(success / config-error / failed)。
- **Acceptance**:
- [ ] 三种结果都有明确 JSON 状态码/字段;session + CSRF 保护。
- [ ] 校验闸门全绿。
### M2-T06 — 前端 scaffold + OpenAPI codegen `[structural]`
- **Status**: `todo` · **Depends**: M2-T01..T05OpenAPI 已稳定)
- **Context**: 建 `frontend/` 工程与类型化 client 流水线,这是后续所有前端任务的地基。
- **Files**: `create frontend/`Vite+React+TS 脚手架、`package.json``tsconfig.json`、eslint、vitest、`.gitignore`)、`frontend/src/api/`codegen 产物 + fetch 封装,自动注入 `X-CSRF-Token`)、`frontend/README.md``npm run codegen` 脚本
- **Steps**: 初始化 Vite React-TS;接 `openapi/openapi.json` 生成类型;写一个最小 App 壳 + 受保护路由骨架;fetch 封装统一带 cookie、写请求注入 CSRF header、401 跳登录。
- **Acceptance**:
- [ ] `npm ci && npm run build` 成功产出 `frontend/dist`
- [ ] `npm run lint``npm run typecheck``npm run test` 全绿(哪怕只有 1 个 smoke 测试)。
- [ ] `npm run codegen` 生成物与当前 `openapi/openapi.json` 一致(CI 可校验)。
- **Reviewer**: client 全部基于生成类型;CSRF/cookie/401 处理在统一封装层;无手写、与契约不符的请求类型。
### M2-T07 — 鉴权 UI(登录 / 会话引导 / 改密)
- **Status**: `todo` · **Depends**: M2-T06
- **Acceptance**: 登录成功进受保护区;未登录访问受保护路由跳登录;强制改密流程可走完;`build/lint/typecheck/test` 全绿。
### M2-T08 — 配置 UI(取代 Jinja config 页)
- **Status**: `todo` · **Depends**: M2-T06
- **Acceptance**: 能读/存所有现有配置 section;secret 不回显、留空保留;SMTP 测试按钮反映三态;前端闸门全绿。
### M2-T09 — 数据可视化 UI(地图 + 热力图)
- **Status**: `todo` · **Depends**: M2-T06(数据来自 T03
- **Context**: 接管 Grafana 原职责:location 轨迹/热力图、poo 点位。
- **Acceptance**: 地图渲染 location/poo 点;热力图层可切换;时间范围筛选生效;前端闸门全绿。
### M2-T10 — 记录管理 UI(按需展示 + 增删改)
- **Status**: `todo` · **Depends**: M2-T06CRUD 来自 T04
- **Acceptance**: 列表分页展示 poo/location;可编辑、可删除单条并即时刷新;删除有二次确认;前端闸门全绿。
### M2-T11 — FastAPI 托管 SPA + 移除 Jinja
- **Status**: `todo` · **Depends**: M2-T07, T08, T09, T10
- **Files**: `modify app/main.py`(挂载 SPA 静态目录 + 非 `/api` 路径回退 `index.html`);`delete app/templates/``app/api/routes/pages.py`(功能对齐后);`modify tests`(移除 Jinja 页面测试,新增 SPA fallback 测试)
- **Acceptance**:
- [ ] `/config` 等路径返回 SPA`index.html`),`/api/*` 不被 fallback 吞掉,`/static`/资源正常。
- [ ] 旧 Jinja 模板与 pages 路由移除后 `pytest` 全绿。
- [ ] 校验闸门全绿(含 OpenAPI 重导出)。
- **Reviewer**: fallback 不拦截 `/api``/docs``/openapi.json`、静态资源;未登录访问 API 仍 401(不是被 SPA 壳吞掉)。
### M2-T12 — 多阶段 Dockerfile + CI/compose
- **Status**: `todo` · **Depends**: M2-T11
- **Files**: `modify Dockerfile`node build 阶段 → 拷 `dist` 进 python 镜像);`modify .github/workflows/*`(加前端 build/lint/typecheck);`modify tests/test_deployment.py`(镜像断言更新)
- **Acceptance**:
- [ ] 镜像构建成功且运行镜像不含 node 运行时。
- [ ] CI 跑前端闸门 + 后端 `pytest`
- [ ] 校验闸门全绿。
### M2-T13 — 文档 + OpenAPI 收尾
- **Status**: `todo` · **Depends**: M2-T12
- **Acceptance**: README 增"前端 v2"段(开发/构建说明);architecture 退役"不前后端分离"约束;roadmap 勾选 M2`openapi/` 已同步入库。
---
## 8. 前端校验闸门(前端任务每次结束都要全绿)
`frontend/` 下:
```bash
npm ci
npm run codegen # 生成类型化 client;产物须与 openapi/openapi.json 同步
npm run lint
npm run typecheck
npm run test
npm run build # 必须产出 dist
```
- 后端若同任务改了路由/schema,仍需根目录 `python scripts/export_openapi.py` 并提交 `openapi/`
- "codegen 产物与 OpenAPI 同步"应在 CI 校验(生成后 `git diff --exit-code`)。
## 9. 里程碑完成定义(DoD
- 访问应用得到 React SPA;配置、可视化、记录增删改都在 SPA 内完成。
- 所有浏览器交互走 `/api` JSON 端点,session + CSRF 保护;ingestion 裸端点维持现状(留给 M3)。
- Jinja `templates/` 与 pages 路由移除;FastAPI 同源托管 SPA。
- 多阶段镜像构建通过;CI 含前端闸门。
- 后端 `pytest`/`ruff`/`export_openapi` + 前端 `build/lint/typecheck/test` 全绿。
+109
View File
@@ -0,0 +1,109 @@
# M3 — Token 鉴权与移动端(远期试水)
> 阅读前提:先读 [`README.md`](./README.md)。M3 依赖 M2(已有 `/api` JSON 契约与 session 鉴权)。
>
> **定位**:远期、低投入、探索性。React Native 部分主要是"没做过、试试水"。范围**可能收缩**——其中**token 鉴权 + ingestion 端点收口**是有持久价值的安全改进,应优先;RN app 是加分项。Orchestrator 可只取前半。
## 1. 目标
1. 引入 **bearer token 鉴权**,让非浏览器客户端(移动端、设备脚本)能安全访问。
2. 把 M2 暂时维持裸奔的 **ingestion 端点**`POST /location/record``POST /poo/record`)收口到 token 鉴权下。
3. 做一个 **React Native** 移动端,用类 OAuth 流程拿 token 后消费现有 `/api`
## 2. 现状(M2 完成后)
- `/api/*` 走 session cookie + `X-CSRF-Token`
- `app/services/auth.py` 有 server-side session`auth_sessions` 表,token_hash 存储)。
- `POST /location/record``POST /poo/record` 仍**无鉴权**(设备/脚本裸调用)。
## 3. 目标架构
### 3.1 Token 模型
- 新表 `auth_tokens``id``user_id``token_hash`(仅存哈希)、`label`(设备名)、`created_at``expires_at`(可空=长期)、`revoked_at`
- bearer 校验:`Authorization: Bearer <token>` → 哈希比对 `auth_tokens` → 命中且未撤销未过期则认定身份。
### 3.2 类 OAuth 签发流程(无第三方的 Authorization Code 简化版)
1. 移动端在**内置浏览器**打开 `/authorize?...`
2. 用户账号密码登录(走现有 session),页面展示"授权此设备"。
3. 批准后服务端生成**一次性 authorization code**,重定向到 app 深链 `homeautomation://callback?code=...`
4. app 用 code 调 `POST /api/auth/token` 换取 bearer token 并存本地。
> 简化兜底:批准页直接展示一次性 token 由 app 捕获。优先实现重定向 + code 交换的正规版。
### 3.3 统一鉴权依赖
- `/api` 的数据/CRUD 端点接受**session cookie 或 bearer**两者之一(同一套端点同时服务 Web 与移动端)。
- ingestion 端点(location/poo record)改为**要求 bearer**。
### 3.4 React Native
- **Expo + TypeScript**,复用 M2 的 OpenAPI 类型化 client(共享契约)。
- 内置浏览器走 §3.2 流程拿 token;之后所有请求带 `Authorization: Bearer`
## 4. 迁移注意(重要)
- ingestion 端点一旦要求 bearer,**现有调用方(HA/设备脚本)必须先配置 token**,否则记录会中断。
- 上线顺序:先签发 token 能力(T01–T02)→ 给现有设备配 token → 再对 ingestion 端点强制 bearer(T03),避免断流。可设一个过渡开关或灰度。
## 5. 任务依赖图
```
M3-T01 token 模型 + 迁移
└─► M3-T02 签发流程(authorize + code 交换)
└─► M3-T03 统一鉴权依赖 + ingestion 端点收口(含过渡开关)
├─► M3-T04 Web 端 token 管理 UI(列出/撤销设备)
└─► M3-T05 React Native app(试水)
└─► M3-T06 文档收尾
```
## 6. 原子任务(任务卡)
### M3-T01 — token 数据模型 + Alembic 迁移
- **Status**: `todo` · **Depends**: noneM2 完成后)
- **Files**: `create app/models/token.py``alembic_app/versions/<new>_auth_tokens.py``modify app/models/__init__.py``create tests/test_token_model.py`
- **Acceptance**:
- [ ] 迁移在全新库 upgrade 后建出 `auth_tokens` 表;downgrade 可回滚。
- [ ] token 仅以哈希存储(与 `auth_sessions` 同等强度),明文不入库。
- [ ] 校验闸门全绿。
- **Reviewer**: 哈希算法/长度与现有 session token 一致;`expires_at` 可空语义明确。
### M3-T02 — 签发流程:authorize 页 + code 交换端点
- **Status**: `todo` · **Depends**: M3-T01
- **Files**: `create app/api/routes/api/token.py``app/schemas/token.py`;前端 `/authorize` 页(M2 SPA 内);`create tests/test_api_token.py`
- **Acceptance**:
- [ ] 登录用户在 `/authorize` 批准后得到一次性 code`POST /api/auth/token` 用 code 换取 bearercode 一次性且短时效。
- [ ] 未登录访问 `/authorize` 跳登录;无效/过期 code 换取失败。
- [ ] 返回的 bearer 仅此一次明文出现,库中只存哈希。
- [ ] 校验闸门全绿。
- **Reviewer**: code 一次性、绑定用户、短 TTL;深链 redirect 白名单校验,防开放重定向。
### M3-T03 — 统一鉴权依赖 + ingestion 端点收口
- **Status**: `todo` · **Depends**: M3-T02
- **Files**: `modify app/dependencies.py`(新增"cookie 或 bearer"统一身份依赖);`modify app/api/routes/location.py``poo.py`(要求 bearer,带过渡开关);`modify tests`
- **Acceptance**:
- [ ] `/api` 数据/CRUD 端点用合法 bearer 可访问(等价于 session)。
- [ ] ingestion 端点:带合法 bearer 通过,缺/错 token 在强制模式下 401;过渡开关可临时放行(默认关)。
- [ ] 撤销的 token 立即失效。
- [ ] 校验闸门全绿。
- **Reviewer**: 过渡开关默认安全(强制);bearer 与 session 两路鉴权不产生绕过;ingestion 行为变更有测试覆盖。
### M3-T04 — Web 端 token 管理 UI
- **Status**: `todo` · **Depends**: M3-T03
- **Acceptance**: 在 SPA 内可列出已签发设备 token(label/创建时间/最近使用)、可撤销;撤销后该 token 立即失效;前端闸门全绿。
### M3-T05 — React Native app(试水)
- **Status**: `todo` · **Depends**: M3-T03 · `[experimental]`
- **Files**: `create mobile/`Expo + TS,复用 OpenAPI 类型化 client
- **Acceptance**:
- [ ] 内置浏览器走签发流程拿到 token 并安全存储(Keychain/Keystore)。
- [ ] 至少跑通:登录拿 token → 拉取一类数据展示 → 记一条 ingestion。
- [ ] `npm run lint`/`typecheck`/`build`(或 Expo 等价) 全绿。
- **Reviewer**: token 存安全存储而非明文;client 基于共享 OpenAPI 类型。
### M3-T06 — 文档收尾
- **Status**: `todo` · **Depends**: M3-T05
- **Acceptance**: README/architecture 增 token 鉴权与移动端说明;roadmap 勾选 M3;`openapi/` 同步。
## 7. 里程碑完成定义(DoD
- 存在 bearer token 鉴权与签发流程;token 仅哈希存储、可撤销。
- ingestion 端点已收口到 bearer(过渡完成后强制)。
- `/api` 同时支持 session 与 bearer。
- (加分)React Native app 能拿 token 并消费 `/api`
- 后端 + 前端 + 移动端各自校验闸门全绿,`openapi/` 入库。
> 提醒:本里程碑探索性强,T05 可作为独立试水随时叫停,不影响 T01–T04 带来的安全收口价值。
+148
View File
@@ -0,0 +1,148 @@
# Roadmap
本文档记录 `home-automation``v1.0.3` 之后的下一阶段规划。这一阶段不是小修补,而是几次较大的结构性改动:单库化、前端重写、以及远期的移动端试水。
> 每个里程碑的**可执行原子任务**展开在 [`docs/design/`](./design/README.md)M1 [`m1-db-consolidation.md`](./design/m1-db-consolidation.md)、M2 [`m2-frontend-v2.md`](./design/m2-frontend-v2.md)、M3 [`m3-token-mobile.md`](./design/m3-token-mobile.md)。这些文档为 Orchestrator→Implementer→Reviewer 的多模型流水线设计。
## 当前基线(v1.0.3
- FastAPI + 服务端 Jinja 模板页面(目前只有 `/login``/config`
- 三个独立 SQLite 库:
- App DB`sqlite:///./data/app.db`
- Location DB`sqlite:///./data/locationRecorder.db`
- Poo DB`sqlite:///./data/pooRecorder.db`
- 三条独立 Alembic 链:`alembic_app/``alembic_location/``alembic_poo/`
- 单 admin 鉴权(Argon2 + server-side session cookie
- Public IPv4 monitor、SMTP 通知、Location / Poo recorder、Home Assistant in/out、TickTick OAuth
- 数据可视化目前由 Grafana provisioning 承担(仅 location / poo dashboard
- 已有 OpenAPI 导出脚本:`scripts/export_openapi.py`
## 本阶段正式退役的架构约束
`docs/architecture-overview.md` 里有几条当时刻意写死的约束,这一阶段明确退役:
- **“不引入前后端分离”** → 退役。本阶段改为 React SPA(仍由 FastAPI 同源托管,但渲染移到客户端)。
- **“三个独立 DB 不合并”** → 退役。本阶段把 location / poo 合并进 `app.db`
- **Grafana 作为可视化方案** → 退役。可视化由 React 前端自己承担(热力图、地图等)。
保持不变的约束:
- 继续使用 **SQLite**,本阶段不上 Postgres。
- 不引入 Notion。
## 里程碑总览
| 里程碑 | 主题 | 一句话 |
| --- | --- | --- |
| **M1** | 单库化地基 | 把三库合并成单一 `app.db`,清理散落数据层,删掉 Grafana |
| **M2** | 前端 v2 | React SPA 取代 Jinja,承载 config + 可视化 + 记录增删改 |
| **M3** | 开放与移动端(远期试水) | token 鉴权 + React Native 移动端 |
排序原则:**先清地基,再在干净结构上盖楼。** M2 的新 API 和 React 必须建立在合并后的单库之上,否则就是在准备推倒的旧数据层上盖新楼、之后回头返工。
---
## M1 — 单库化地基
### 目标
把 location / poo 两个独立库合并进 `app.db`,借机清理项目早期散落各处的数据访问代码,并移除 Grafana。
### 范围
- **Alembic 收敛为单链(app 链)**location / poo 的表此后纳入 app 链管理;`alembic_location/``alembic_poo/` 退出活跃使用(保留在 git 历史)。
- **新建表(schema only**:在 app 链上加一条 upgrade revision,把原来两个旧库里的表**原样**建到 `app.db` 中。Alembic **不需要知道任何旧数据**——它只负责把 app DB 往上升一个版本、建出这两张新表。
- **数据搬迁交给独立脚本**`scripts/migrate_legacy_data.py`(见下方“迁移策略”),手动跑一次。
- **配置层收敛**:去掉 `LOCATION_DATABASE_URL` / `POO_DATABASE_URL`,统一到 `APP_DATABASE_URL`
- **开启 SQLite WAL**:单文件 + Web + APScheduler 并发写入,开 WAL 更稳。
- **删除 Grafana**:移除 compose 中的 grafana service、`grafana/provisioning/``grafana/dashboards/`。直接删除,不再 re-point datasource。
- **更新文档**README、architecture-overview 同步反映单库现实。
### 注意
- **可视化空窗可接受**:M1 删掉 Grafana 后、到 M2 React 可视化落地之前会有一段没有可视化面板的时间。已确认可以接受。
- **历史数据是第一优先级,绝不能丢**(见“数据安全原则”)。
---
## 迁移策略(M1 核心)
职责拆分得很清楚:**Alembic 管 schema,脚本管数据。**
### Alembic revision(只建结构)
- 一条 app 链上的 upgrade revision,建出与旧库**完全相同**的表结构。
- 确定性、与环境无关:在生产机、CI、全新部署上都一样地建空表,不依赖任何旧文件是否存在。
- 本步**只原样挪表,不顺手改 schema**。任何表结构清理留到之后一条单独的 migration 去做——不可替代的历史数据,一次只承担一种风险。
### 数据搬迁脚本(`scripts/migrate_legacy_data.py`
- 把旧 `locationRecorder.db` / `pooRecorder.db` 里的行,拷进 `app.db` 的新表(SQLite `ATTACH DATABASE` 或单独连接均可)。
- **幂等**:重复运行不会重复插入。
- **搬完对账**:逐表核对源 / 目标行数,对不上就报错中止。
- 只在生产机上**手动跑一次**,不进 Alembic 永久链路(避免把一次性历史搬迁焊死进每次全新建库都要跑的链路里)。
### 旧库的“撤掉”
- “撤掉旧库” = ① 配置不再指向它们 + ② 文件**归档保留**。
- **绝不**在任何脚本 / migration 里 `os.remove` 旧文件——那不可逆,且踩数据安全红线。
- 真正的删除是**人工、最后、确认无误之后**单独的一步。
---
## 数据安全原则
历史数据(location / poo 记录)是这个项目里最不可替代的东西,迁移期间一律按以下原则:
1. **迁移前先归档**旧 `.db` 文件一份。
2. **先在副本上演练**:把每日备份恢复到一个 scratch 目录,在副本上跑完整迁移、核对行数无误,再对真实库动手。
3. **脚本幂等 + 行数对账**,对不上立即中止。
4. **旧文件只读归档、绝不自动删除**,删除是事后人工动作。
---
## M2 — 前端 v2React SPA
### 目标
用 React SPA 取代现有 Jinja 页面,由 FastAPI 同源托管(同一容器、同一 origin)。这一步合并了“前端重写为 React”和“前端做厚”两件原本分开的事——它们本质是同一坨活。
> 备注:React 是一次 agentic programming 试水。之前只手写过 Vue、没手写过 React,这一轮想全程靠 agent、尽量不读代码地把它做出来。OpenAPI 导出 → 生成类型化 TS client 作为 agent 的契约护栏,正好服务这个目标。
### 范围
- **React SPA**FastAPI 挂载打包后的静态产物(同源,省掉 CORS)。
- **Config 界面**:取代现有 Jinja config 页。
- **数据可视化**:热力图、地图等,接管原先 Grafana 干的事。
- **按需展示 DB 数据**(例如 poo 记录)。
- **记录的小幅增删改**:用于修正不准确的记录。
### 后端配套
- **补一套 JSON API**:SPA 是客户端渲染,需要后端提供 config 读写、数据查询、记录 CRUD 等 JSON 端点。(同源不等于不需要 API——API 是“客户端怎么拿数据”,与文件托管在哪无关。)
- **鉴权**:浏览器面向的新端点(含记录 CRUD)复用现有 session cookie 保护。
- **类型化 client**:用 `scripts/export_openapi.py` 的输出生成 TS client。
### 鉴权边界(与 M3 衔接)
- 现在那个“裸 API 记小狗日志”的 ingestion 端点(设备 / 脚本调用,非浏览器)**维持现状到 M3**。
- M2 新增的、浏览器调用的 CRUD 端点,用 session 保护即可,本步不引入 token。
---
## M3 — 开放与移动端(远期试水)
### 目标
引入 token 鉴权并做一个 React Native 移动端。**明确是很远期、低投入的试水**——先把 React 前端做出来,之后才会碰移动端,且主要是想试试没做过的 React / React Native。
### 范围
- **OAuth-lite token 签发**:移动端在内置浏览器里用账号密码登录,走一遍类 OAuth 流程,服务端签发一个 bearer token 给 app 存起来使用。(本质是没有第三方的 Authorization Code 简化版。)
- **React Native 移动端**:试水性质。
- **给 ingestion 端点上 token**:把 M2 暂时维持裸奔的设备端点收口到 token 鉴权下。
### 为什么放最后
- 移动端是这一阶段最远期、最不确定的部分。
- token 主要是移动端的前置条件;Web 端 React 用现有 session cookie 即可,不需要为它提前引入 token。