diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1b11b95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitignore +.pytest_cache +.venv +__pycache__ +*.pyc +data +openapi +src + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d9c7351 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +APP_NAME=Home Automation Backend (Python) +APP_ENV=development +APP_DEBUG=true +APP_HOST=0.0.0.0 +APP_PORT=8000 +DATABASE_URL=sqlite:///./data/app.db +TICKTICK_CLIENT_ID= +TICKTICK_CLIENT_SECRET= +TICKTICK_REDIRECT_URI=http://localhost:8000/ticktick/auth/callback +TICKTICK_TOKEN= +HOME_ASSISTANT_BASE_URL=http://localhost:8123 +HOME_ASSISTANT_AUTH_TOKEN= +HOME_ASSISTANT_ACTION_TASK_PROJECT_ID= + diff --git a/.gitignore b/.gitignore index 4bd78c5..1d5c43b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,37 +1,8 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file .env - -temp_data/ - -# py file for branch switching -.venv -__pycache__/ .pytest_cache/ -config.yaml -bin/ -*.db +.venv/ +__pycache__/ +*.pyc +data/ +openapi/ -cover.html \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index c6a1460..5d9517f 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,35 +1,20 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "Launch Package", - "type": "go", + "name": "Launch Python App", + "type": "debugpy", "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}" - }, - { - "name": "Launch Poo Reverse", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}/src/helper/poo_recorder_helper/main.go", + "module": "uvicorn", "args": [ - "reverse" - ] - }, - { - "name": "Launch Home Automation", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${workspaceFolder}/src/main.go", - "args": [ - "serve" - ] + "app.main:app", + "--reload", + "--host", + "0.0.0.0", + "--port", + "8000" + ], + "jinja": true } ] -} \ No newline at end of file +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ad5727 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app +COPY alembic ./alembic +COPY alembic.ini ./ +COPY scripts ./scripts +COPY README.md ./ +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 47d85dc..2e20eda 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,177 @@ # Home Automation Backend -![CI-Short](https://code.wanderingbadger.dev/tliu93/home-automation-backend.git/actions/workflows/short-tests.yml/badge.svg) \ No newline at end of file +这是当前 `home-automation` 项目的 Python 重构基础骨架。当前仓库仍保留 Go 版本作为事实基线,而这个 Python 部分的目标是为后续逐模块迁移提供稳定工程基础。 + +为便于清理仓库,重构开始前就存在的 Go 实现和相关资产已经统一移动到 `legacy/go-backend/`。这样在 Python 重构完成后,可以按目录整体删除旧实现。 + +当前阶段只包含: + +- FastAPI 基础应用骨架 +- 环境变量配置体系 +- SQLite + SQLAlchemy + Alembic 基础设施 +- 极简 server-side templates +- pytest 测试基础 +- OpenAPI 导出脚本 +- Docker / Compose 基础骨架 + +当前阶段明确不包含: + +- TickTick 业务逻辑迁移 +- Home Assistant 业务逻辑迁移 +- poo records 业务迁移 +- location / life trajectory 业务迁移 +- Notion 模块 + +Notion 在 Go 版本中仍然存在,但已被明确视为 legacy / removed scope,不进入新的 Python 系统目标。 + +旧 Go 代码位置: + +- `legacy/go-backend/src/` +- `legacy/go-backend/helper/` +- `legacy/go-backend/.github/workflows/` + +## 当前目录 + +Python 骨架的主要目录如下: + +- `app/`: FastAPI 应用代码 +- `alembic/`: Alembic migration 环境 +- `tests/`: pytest 测试 +- `docs/`: 架构说明与迁移文档 +- `scripts/`: 辅助脚本,例如 OpenAPI 导出 + +## 依赖管理 + +项目现在采用 `pip-tools` 管理依赖: + +- 生产依赖源文件:`requirements.in` +- 开发依赖源文件:`dev-requirements.in` +- 编译产物: + - `requirements.txt` + - `dev-requirements.txt` + +更新依赖时建议使用: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install pip-tools +pip-compile requirements.in +pip-compile dev-requirements.in +``` + +如果要升级某个依赖,可以用: + +```bash +pip-compile --upgrade-package fastapi requirements.in +pip-compile dev-requirements.in +``` + +## 本地启动 + +建议使用 Python 3.11 或以上版本。 + +1. 创建虚拟环境并安装依赖 + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r dev-requirements.txt +``` + +2. 准备环境变量 + +```bash +cp .env.example .env +``` + +3. 启动服务 + +```bash +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +启动后可访问: + +- 应用首页:`http://localhost:8000/` +- 健康检查:`http://localhost:8000/status` +- Swagger UI:`http://localhost:8000/docs` +- ReDoc:`http://localhost:8000/redoc` + +## 数据库与 Alembic + +当前默认数据库使用 SQLite。 + +- 默认数据库地址:`sqlite:///./data/app.db` +- 数据目录:`./data/` + +初始化 migration 环境后,可继续添加模型并生成迁移: + +```bash +alembic revision --autogenerate -m "init tables" +alembic upgrade head +``` + +这一轮尚未引入业务表,因此 Alembic 目前主要是基础设施就绪状态。 + +## 运行测试 + +```bash +pytest +``` + +当前测试包含: + +- app 基本启动测试 +- `/status` endpoint 测试 + +## OpenAPI 导出 + +FastAPI 默认会暴露 OpenAPI。若需要导出静态 schema 文件,可运行: + +```bash +python scripts/export_openapi.py +``` + +输出文件会写到: + +- `openapi/openapi.json` +- `openapi/openapi.yaml` + +## 容器启动 + +1. 准备环境变量文件 + +```bash +cp .env.example .env +``` + +2. 启动容器 + +```bash +docker compose up --build +``` + +默认端口: + +- `8000:8000` + +SQLite 持久化目录: + +- 本地 `./data` +- 容器内 `/app/data` + +## 后续迁移建议 + +后续可以在当前骨架上逐步迁移这些模块: + +- TickTick integration +- Home Assistant integration +- poo records +- location / life trajectory + +建议继续参考: + +- [当前系统盘点](docs/current-system-inventory.md) +- [Python 重构方案](docs/python-rewrite-plan.md) +- [迁移风险清单](docs/migration-risks.md) diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..7c7143b --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./data/app.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s + diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..0eb160c --- /dev/null +++ b/alembic/README @@ -0,0 +1,2 @@ +This directory contains the Alembic migration environment for the Python rewrite skeleton. + diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7fa3bc7 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,46 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.models.base import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.database_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..2e8960a --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..09cd07d --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Application package for the Python rewrite skeleton.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..b05dfd6 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""API package.""" + diff --git a/app/api/routes/__init__.py b/app/api/routes/__init__.py new file mode 100644 index 0000000..216c2a5 --- /dev/null +++ b/app/api/routes/__init__.py @@ -0,0 +1,2 @@ +"""Route modules.""" + diff --git a/app/api/routes/pages.py b/app/api/routes/pages.py new file mode 100644 index 0000000..2bca83b --- /dev/null +++ b/app/api/routes/pages.py @@ -0,0 +1,21 @@ +from pathlib import Path + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from app.config import Settings +from app.dependencies import get_app_settings + +templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates")) +router = APIRouter(tags=["pages"]) + + +@router.get("/", response_class=HTMLResponse) +def home(request: Request, settings: Settings = Depends(get_app_settings)) -> HTMLResponse: + context = { + "app_name": settings.app_name, + "app_env": settings.app_env, + "notion_status": "Legacy scope, removed from the Python rewrite target.", + } + return templates.TemplateResponse(request, "home.html", context) diff --git a/app/api/routes/status.py b/app/api/routes/status.py new file mode 100644 index 0000000..0e21cc2 --- /dev/null +++ b/app/api/routes/status.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.schemas.health import StatusResponse + +router = APIRouter(tags=["system"]) + + +@router.get("/status", response_model=StatusResponse) +def get_status() -> StatusResponse: + return StatusResponse(status="ok") + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..d42e2d4 --- /dev/null +++ b/app/config.py @@ -0,0 +1,50 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Home Automation Backend (Python)" + app_env: str = "development" + app_debug: bool = False + app_host: str = "0.0.0.0" + app_port: int = 8000 + + database_url: str = "sqlite:///./data/app.db" + + ticktick_client_id: str = "" + ticktick_client_secret: str = "" + ticktick_redirect_uri: str = "" + ticktick_token: str = "" + + home_assistant_base_url: str = "" + home_assistant_auth_token: str = "" + home_assistant_action_task_project_id: str = "" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + ) + + @computed_field + @property + def is_development(self) -> bool: + return self.app_env.lower() == "development" + + @computed_field + @property + def sqlite_path(self) -> Path | None: + prefix = "sqlite:///" + if not self.database_url.startswith(prefix): + return None + raw_path = self.database_url[len(prefix) :] + return Path(raw_path) + + +@lru_cache +def get_settings() -> Settings: + return Settings() + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..efbb463 --- /dev/null +++ b/app/db.py @@ -0,0 +1,29 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.config import get_settings + + +class Base(DeclarativeBase): + pass + + +settings = get_settings() + +connect_args: dict[str, object] = {} +if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine(settings.database_url, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, class_=Session) + + +def get_db_session() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() + diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..fb2f700 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,15 @@ +from collections.abc import Generator + +from sqlalchemy.orm import Session + +from app.config import Settings, get_settings +from app.db import get_db_session + + +def get_app_settings() -> Settings: + return get_settings() + + +def get_db() -> Generator[Session, None, None]: + yield from get_db_session() + diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py new file mode 100644 index 0000000..999e12a --- /dev/null +++ b/app/integrations/__init__.py @@ -0,0 +1,2 @@ +"""External integration placeholders for future migration.""" + diff --git a/app/integrations/homeassistant.py b/app/integrations/homeassistant.py new file mode 100644 index 0000000..944839c --- /dev/null +++ b/app/integrations/homeassistant.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.config import Settings + + +@dataclass(slots=True) +class HomeAssistantClient: + settings: Settings + + def is_configured(self) -> bool: + return bool(self.settings.home_assistant_base_url and self.settings.home_assistant_auth_token) + diff --git a/app/integrations/ticktick.py b/app/integrations/ticktick.py new file mode 100644 index 0000000..8dc15c3 --- /dev/null +++ b/app/integrations/ticktick.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.config import Settings + + +@dataclass(slots=True) +class TickTickClient: + settings: Settings + + def is_configured(self) -> bool: + return bool(self.settings.ticktick_client_id and self.settings.ticktick_client_secret) + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e92b0cd --- /dev/null +++ b/app/main.py @@ -0,0 +1,45 @@ +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.api.routes import pages, status +from app.config import get_settings + + +def ensure_runtime_dirs() -> None: + settings = get_settings() + if settings.sqlite_path is not None: + settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + ensure_runtime_dirs() + yield + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title=settings.app_name, + debug=settings.app_debug, + version="0.1.0", + lifespan=lifespan, + description=( + "Python rewrite skeleton for the home automation backend. " + "This stage provides only the foundation for future module migration." + ), + ) + + static_dir = Path(__file__).parent / "static" + app.mount("/static", StaticFiles(directory=static_dir), name="static") + + app.include_router(status.router) + app.include_router(pages.router) + return app + + +app = create_app() + diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..becfbcb --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,2 @@ +"""SQLAlchemy models package.""" + diff --git a/app/models/base.py b/app/models/base.py new file mode 100644 index 0000000..b852be4 --- /dev/null +++ b/app/models/base.py @@ -0,0 +1,4 @@ +from app.db import Base + +__all__ = ["Base"] + diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..660f49e --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1,2 @@ +"""Pydantic schemas package.""" + diff --git a/app/schemas/health.py b/app/schemas/health.py new file mode 100644 index 0000000..cc351f8 --- /dev/null +++ b/app/schemas/health.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class StatusResponse(BaseModel): + status: str + diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..b234c8f --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,2 @@ +"""Service layer package.""" + diff --git a/app/services/system.py b/app/services/system.py new file mode 100644 index 0000000..d2f4f75 --- /dev/null +++ b/app/services/system.py @@ -0,0 +1,6 @@ +from app.config import Settings + + +def build_status_payload(settings: Settings) -> dict[str, str]: + return {"status": "ok", "environment": settings.app_env} + diff --git a/app/static/styles.css b/app/static/styles.css new file mode 100644 index 0000000..eddcec4 --- /dev/null +++ b/app/static/styles.css @@ -0,0 +1,95 @@ +:root { + --bg: #f4f1ea; + --panel: rgba(255, 255, 255, 0.88); + --text: #1f2933; + --muted: #5b6875; + --accent: #2d6a4f; + --border: rgba(31, 41, 51, 0.08); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(45, 106, 79, 0.18), transparent 28%), + linear-gradient(160deg, #f7f4ee 0%, #ece6d8 100%); +} + +.shell { + width: min(880px, calc(100% - 32px)); + margin: 48px auto; +} + +.panel { + padding: 32px; + border: 1px solid var(--border); + border-radius: 24px; + background: var(--panel); + backdrop-filter: blur(12px); + box-shadow: 0 20px 60px rgba(31, 41, 51, 0.12); +} + +.eyebrow { + margin: 0 0 8px; + font-size: 0.85rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +h1 { + margin: 0 0 16px; + font-size: clamp(2rem, 4vw, 3.2rem); +} + +.lead { + margin: 0 0 24px; + line-height: 1.7; + color: var(--muted); +} + +.meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin: 0; +} + +.meta div { + padding: 16px; + border-radius: 16px; + background: rgba(255, 255, 255, 0.7); + border: 1px solid rgba(31, 41, 51, 0.06); +} + +.meta dt { + margin-bottom: 8px; + font-size: 0.9rem; + color: var(--muted); +} + +.meta dd { + margin: 0; + font-size: 1.05rem; +} + +a { + color: var(--accent); +} + +@media (max-width: 640px) { + .shell { + margin: 24px auto; + } + + .panel { + padding: 24px; + } +} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..5c55712 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,15 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +
+ {% block content %}{% endblock %} +
+ + + diff --git a/app/templates/home.html b/app/templates/home.html new file mode 100644 index 0000000..559a65a --- /dev/null +++ b/app/templates/home.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}{{ app_name }}{% endblock %} + +{% block content %} +
+

Python Rewrite Skeleton

+

{{ app_name }}

+

+ 这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、 + 测试、模板和容器化基础,不包含业务逻辑迁移。 +

+
+
+
运行环境
+
{{ app_env }}
+
+
+
健康检查
+
/status
+
+
+
OpenAPI
+
/docs
+
+
+
Notion
+
{{ notion_status }}
+
+
+
+{% endblock %} + diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..64e8cc4 --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,6 @@ +-r requirements.in + +httpx>=0.28,<1.0 +pip-tools>=7.4,<8.0 +pytest>=8.3,<9.0 + diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..ed4276e --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,120 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile dev-requirements.in +# +alembic==1.18.4 + # via -r requirements.in +annotated-types==0.7.0 + # via pydantic +anyio==4.13.0 + # via + # httpx + # starlette + # watchfiles +build==1.4.3 + # via pip-tools +certifi==2026.2.25 + # via + # httpcore + # httpx +click==8.3.2 + # via + # pip-tools + # uvicorn +fastapi==0.115.14 + # via -r requirements.in +greenlet==3.4.0 + # via sqlalchemy +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httptools==0.7.1 + # via uvicorn +httpx==0.28.1 + # via -r dev-requirements.in +idna==3.11 + # via + # anyio + # httpx +iniconfig==2.3.0 + # via pytest +jinja2==3.1.6 + # via -r requirements.in +mako==1.3.11 + # via alembic +markupsafe==3.0.3 + # via + # jinja2 + # mako +packaging==26.1 + # via + # build + # pytest + # wheel +pip-tools==7.5.3 + # via -r dev-requirements.in +pluggy==1.6.0 + # via pytest +pydantic==2.13.2 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.2 + # via pydantic +pydantic-settings==2.13.1 + # via -r requirements.in +pygments==2.20.0 + # via pytest +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pytest==8.4.2 + # via -r dev-requirements.in +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.26 + # via -r requirements.in +pyyaml==6.0.3 + # via + # -r requirements.in + # uvicorn +sqlalchemy==2.0.49 + # via + # -r requirements.in + # alembic +starlette==0.46.2 + # via fastapi +typing-extensions==4.15.0 + # via + # alembic + # fastapi + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings +uvicorn[standard]==0.44.0 + # via -r requirements.in +uvloop==0.22.1 + # via uvicorn +watchfiles==1.1.1 + # via uvicorn +websockets==16.0 + # via uvicorn +wheel==0.46.3 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c8b447a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.9" + +services: + app: + build: . + ports: + - "8000:8000" + env_file: + - .env + environment: + DATABASE_URL: sqlite:////app/data/app.db + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + volumes: + - ./data:/app/data + diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md new file mode 100644 index 0000000..a2c878e --- /dev/null +++ b/docs/architecture-overview.md @@ -0,0 +1,79 @@ +# Python 骨架架构概览 + +本文档说明当前 Python skeleton 的职责边界与目录组织。它描述的是“后续迁移承载体”,不是完整业务实现。 + +## 当前目标 + +这一轮的目标是提供一个稳定、轻量、可持续扩展的基础工程,使后续可以逐步迁移: + +- TickTick integration +- Home Assistant integration +- poo records +- location / life trajectory + +## 目录设计 + +### `app/` + +应用核心代码目录。 + +- `main.py` + - FastAPI app factory + - lifespan + - 基础路由注册 +- `config.py` + - 环境变量驱动的 settings +- `db.py` + - SQLAlchemy engine / session / Base +- `dependencies.py` + - 通用依赖注入 +- `api/` + - HTTP routes +- `models/` + - SQLAlchemy models +- `schemas/` + - Pydantic schemas +- `services/` + - 业务服务层 +- `integrations/` + - 外部系统适配层占位 +- `templates/` + - Jinja2 模板 +- `static/` + - 极简静态资源 + +### `alembic/` + +数据库 migration 基础设施。当前尚未迁入业务表,但迁移链路已就绪。 + +### `tests/` + +pytest 测试目录。后续可以在这里自然扩展: + +- unit tests +- mock tests +- integration tests + +### `scripts/` + +辅助脚本目录。当前包含 OpenAPI 导出脚本。 + +## 当前约束 + +- 当前只搭骨架,不迁业务逻辑 +- 当前数据库继续使用 SQLite +- 当前不引入前后端分离 +- 当前不设计 Notion 模块 + +## 关于 Notion + +Notion 在 Go 版本中仍是现状模块,但在 Python 重构中已经明确属于 removed scope。 + +因此当前 Python skeleton: + +- 不提供 Notion integration 模块 +- 不提供 Notion schema +- 不预留 Notion 相关业务流 + +如果未来需要回顾其历史作用,应继续参考 Go 版本和现有迁移盘点文档,而不是在 Python 骨架中保留它。 + diff --git a/docs/current-system-inventory.md b/docs/current-system-inventory.md new file mode 100644 index 0000000..c85cfb1 --- /dev/null +++ b/docs/current-system-inventory.md @@ -0,0 +1,557 @@ +# 当前系统盘点 + +本文档用于盘点当前 branch 上的 Go 实现,并将其作为后续 Python 重构的唯一事实基线。 + +## 范围与基线 + +- 当前事实基线:`legacy/go-backend/src/` 下的 Go 代码 +- 不纳入当前基线:更早的 Python 版本 +- 主入口:[`legacy/go-backend/src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) + +## 系统概览 + +当前应用是一个单进程 Go HTTP 服务,具备以下特征: + +- 暴露少量 REST API +- 使用本地 SQLite 做持久化 +- 调用 Home Assistant API 和 webhook +- 通过 OAuth 和 REST API 集成 TickTick +- 当前仍依赖 Notion 做 poo 记录同步 +- 内置一个每日执行的定时同步任务 + +进程启动后会先读取 YAML 配置文件,再初始化 Notion 与 TickTick 工具层、初始化各业务组件自己的 SQLite 数据库、注册路由、启动调度器,最后在配置的端口上提供 HTTP 服务。可参考 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) 和 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:104)。 + +## API 盘点 + +### `GET /status` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:106) +- 用途:基础存活检查 +- 请求参数:无 +- 请求体:无 +- 响应:纯文本 `OK` +- 鉴权:当前代码中无鉴权 +- 调用方类型:通用健康检查,可能用于本地监控或 supervisor 级别的探活 + +### `GET /poo/latest` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:110) +- 处理函数:[`pooRecorder.HandleNotifyLatestPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:87) +- 用途:将最新一条 poo 状态重新发布到 Home Assistant 的 sensor state +- 请求参数:无 +- 请求体:无 +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 失败:通过 `http.Error(...)` 返回文本错误信息 +- 鉴权:当前代码中无鉴权 +- 外部调用方: + - 会被 `POST /homeassistant/publish` 间接触发,当 `target=poo_recorder` 且 `action=get_latest` 时,代码会通过内部 HTTP 请求访问 `http://localhost:{port}/poo/latest`,见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:110) +- 副作用: + - 从 `poo_records` 读取最新一条记录 + - 调用 Home Assistant `/api/states/{entity_id}` 更新 sensor 状态,见 [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:65) + +### `POST /poo/record` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:111) +- 处理函数:[`pooRecorder.HandleRecordPoo`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) +- 用途:记录一条 poo 事件,同时镜像到 Notion、刷新 Home Assistant sensor,并可选触发一个 Home Assistant webhook +- 请求体 JSON: + - `status: string` + - `latitude: string` + - `longitude: string` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` + - 如果配置里缺少 `pooRecorder.tableId`,请求会直接失败,虽然从纯本地 DB 角度看本来仍有可能写入成功 +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 请求错误:返回 decoder 错误文本,HTTP 400 + - 服务端错误:返回错误文本,HTTP 500 +- 鉴权:当前代码中无鉴权 +- 外部调用方:大概率是 Home Assistant、移动端 shortcut、或手工调用;代码中没有明确写死调用方 +- 副作用: + - 向 SQLite `poo_records` 插入一条记录 + - 异步向 Notion 追加一行 + - 同步发布最新 Home Assistant sensor 状态 + - 如果存在 `pooRecorder.webhookId`,异步触发一个 Home Assistant webhook + +### `POST /homeassistant/publish` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:112) +- 处理函数:[`HomeAssistant.HandleHaMessage`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) +- 用途:接收自动化消息,根据 `target` 和 `action` 做分发 +- 请求体 JSON: + - `target: string` + - `action: string` + - `content: string` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` +- 响应: + - 成功路径:空响应体,默认 HTTP 200 + - 失败路径:通常为空响应体并返回 HTTP 500;TickTick auth 回调是单独的接口,不在这里 +- 鉴权:当前代码中无鉴权 +- 外部调用方:设计意图上是给 Home Assistant automation 消息调用 + +当前代码支持的消息契约如下: + +- `target=poo_recorder`, `action=get_latest` + - 转发到本地 `GET /poo/latest` + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:72) + +- `target=location_recorder`, `action=record` + - `content` 预期是一个 JSON 风格字符串,实际很可能使用单引号 + - 当前代码会用 `strings.ReplaceAll(message.Content, "'", "\"")` 做归一化 + - 然后转发到本地 `POST /location/record` + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) + +- `target=ticktick`, `action=create_action_task` + - `content` 预期可解析为: + - `action: string` + - `due_hour: int` + - 当前代码会忽略调用方传来的 `title` 字段,而是把 `action` 映射为 TickTick task title + - 到期时间的计算方式是:取 `now + due_hour` 后所在日期的“次日零点”,再转成 TickTick 使用的时间格式 + - 最终在配置指定的 TickTick project 中创建任务 + - 见 [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:124) + +不支持的 `target` 或 `action` 会返回 HTTP 500,并打 warning 日志。相关测试在 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:68)。 + +### `POST /location/record` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:114) +- 处理函数:[`locationRecorder.HandleRecordLocation`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:43) +- 用途:记录人的位置点,用于人生轨迹 / movement history +- 请求体 JSON: + - `person: string` + - `latitude: string` + - `longitude: string` + - `altitude: string`,从请求结构上看是可选,但代码里即使为空也会被解析成 `0` +- 请求校验: + - JSON decoder 开启了 `DisallowUnknownFields` + - 数值解析错误会被忽略;如果 `latitude` / `longitude` / `altitude` 不是合法数字,当前实现会静默落成 `0` +- 响应: + - 成功:空响应体,默认 HTTP 200 + - 请求错误:返回 decoder 错误文本,HTTP 400 +- 鉴权:当前代码中无鉴权 +- 外部调用方: + - 可被任意客户端直接调用 + - 也会被 `POST /homeassistant/publish` 间接触发 +- 副作用: + - 向 SQLite `location` 表插入一条记录 + +### `GET /ticktick/auth/code` + +- 路由定义:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:116) +- 处理函数:[`TicktickUtilImpl.HandleAuthCode`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) +- 用途:TickTick OAuth redirect callback +- Query 参数: + - `state` + - `code` +- 响应: + - 成功:纯文本 `Authorization successful` + - 失败:纯文本错误信息,HTTP 400 或 500 +- 鉴权: + - 通过 OAuth `state` 与进程内保存的 `authState` 做校验 + - 没有额外的 session 或用户级鉴权 +- 外部调用方:TickTick OAuth redirect +- 副作用: + - 用 authorization code 换取 access token + - 通过 `viper.WriteConfig()` 把 `ticktick.token` 写回 YAML 配置文件 + +## 外部集成盘点 + +### TickTick + +- 在 Python 重构中的状态:应保留 +- 主要文件: + - [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:1) + - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:100) +- 当前职责: + - 初始化 TickTick 鉴权状态 + - 当 token 缺失时启动 OAuth 授权 + - 接收 OAuth callback 并持久化 token + - 读取 project 下的 tasks + - 若不存在同名任务,则创建新任务 +- 连接方式: + - OAuth authorization code flow + - 调用 `https://ticktick.com/oauth/token` + - 调用 `https://api.ticktick.com/open/v1/...` +- 依赖的配置项: + - `ticktick.clientId` + - `ticktick.clientSecret` + - `ticktick.redirectUri` + - `ticktick.token` +- 关键实现依赖: + - 原生 `net/http` + - `viper`,同时承担配置读取和配置回写 +- 迁移高风险点: + - OAuth callback 的 `state` 只保存在进程内;如果服务在授权开始和回调完成之间重启,流程会断 + - token 直接写回 YAML 配置文件,虽然简单,但运维上比较脆弱 + - 去重逻辑只按 task title 精确匹配 + - due date 的计算语义是隐含在代码里的,重构前应先冻结 + - `Init()` 会在启动时积极检查配置,且在 token 缺失时打印手动授权 URL;Python 版需要明确是否仍要在启动阶段卡住这一流程 + +### Home Assistant + +- 在 Python 重构中的状态:应保留 +- 主要文件: + - [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:1) + - [`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:1) +- 当前职责: + - 接收来自 Home Assistant automations 的命令 envelope + - 将命令转发给本地模块 + - 把 sensor state 发布回 Home Assistant + - 在 poo 记录后触发 Home Assistant webhook +- 连接方式: + - 入站 webhook 风格 JSON 接口:`POST /homeassistant/publish` + - 出站 REST 调用:Home Assistant `/api/states/{entity_id}` + - 出站 webhook 调用:`/api/webhook/{webhook_id}` + - 出站调用使用 bearer token +- 依赖的配置项: + - `homeassistant.ip` + - `homeassistant.port` + - `homeassistant.authToken` + - `homeassistant.actionTaskProjectId` + - `pooRecorder.webhookId` + - `pooRecorder.sensorEntityName` + - `pooRecorder.sensorFriendlyName` +- 关键实现依赖: + - 原生 `net/http` + - 通过 `localhost:{port}` 发起自调用,而不是直接走函数调用 +- 迁移高风险点: + - 入站 `/homeassistant/publish` 当前没有鉴权 + - 当前命令 envelope 里的 `content` 是字符串,且常带单引号,现有客户端可能依赖这种非标准格式 + - 模块间当前是通过自调用 HTTP 和 1 秒 timeout 编排的 + - sensor 发布和 webhook 触发都属于强副作用行为,需要在兼容性测试里单独覆盖 + +### Notion + +- 在 Python 重构中的状态:当前存在,但按已知目标不计划默认保留 +- 主要文件: + - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:1) + - [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) + - helper CLI:[`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) +- 当前职责: + - 使用 config token 初始化 Notion client + - 读取 / 写入 poo 记录对应的表格行 + - 每日做 SQLite 和 Notion 的双向同步 + - 提供一个反转 Notion 表顺序的辅助 CLI +- 连接方式: + - 通过 `github.com/jomei/notionapi` 调用 Notion API + - token 鉴权 +- 依赖的配置项: + - `notion.token` + - `pooRecorder.tableId` +- 关键实现依赖: + - `github.com/jomei/notionapi` +- 迁移高风险点: + - 当前服务启动时如果缺少 `notion.token` 会直接退出,即便 Notion 并不是系统所有功能都需要的基础能力 + - `POST /poo/record` 当前要求 `pooRecorder.tableId` 存在,并会异步镜像到 Notion + - 每日定时同步会同时改写 Notion 和 SQLite,若 Python 版移除这一行为,数据一致性预期会发生变化 + +## 数据库与 Schema 盘点 + +### 数据库类型 + +- 当前使用 SQLite 做组件级持久化 +- poo recorder 中显式导入了 SQLite driver,见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:20) +- location recorder 也通过 driver 名 `sqlite` 打开 SQLite,见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:80) + +### Poo recorder 数据库 + +- 配置项:`pooRecorder.dbPath` +- 默认路径:`pooRecorder.db` +- migration 机制: + - 手写 `PRAGMA user_version` + - 当前有效版本可以认为是 `1` + - 目前只实现了 `0 -> 1` +- 表: + - `poo_records` + - schema 定义见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:162) +- 字段: + - `timestamp TEXT PRIMARY KEY` + - `status TEXT NOT NULL` + - `latitude REAL NOT NULL` + - `longitude REAL NOT NULL` +- 核心用途: + - 用于查询最新 poo 状态并发布到 Home Assistant sensor + - 作为本地 poo 历史的持久化来源 + - 作为 Notion 双向同步的本地数据源和数据汇 +- 明显核心字段: + - `timestamp` + - `status` + - `latitude` + - `longitude` +- 可能属于历史包袱 / 后续需要再判断的点: + - 当前实现与 Notion 表行结构高度耦合 + - 时间戳是字符串,格式为 `2006-01-02T15:04Z07:00`,不是带秒的完整 RFC3339 + - API 请求模型接受的经纬度是字符串,因此 Python 版的类型规范化要小心兼容 + +### Location recorder 数据库 + +- 配置项:`locationRecorder.dbPath` +- 默认路径:`location_recorder.db` +- migration 机制: + - 手写 `PRAGMA user_version` + - 当前版本 `2` + - 已实现 migration: + - `0 -> 1`:建表 + - `1 -> 2`:把旧 datetime 字符串改写成 RFC3339 UTC +- 表: + - `location` + - schema 定义见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:115) +- 字段: + - `person TEXT NOT NULL` + - `datetime TEXT NOT NULL` + - `latitude REAL NOT NULL` + - `longitude REAL NOT NULL` + - `altitude REAL` + - 主键 `(person, datetime)` +- 核心用途: + - 持久化人生轨迹 / 位置点记录 +- 明显核心字段: + - `person` + - `datetime` + - `latitude` + - `longitude` +- 可能属于历史包袱 / 后续需要再判断的点: + - `altitude` 在语义上是可选,但当前入站解析会把缺失和非法值一起压成 `0` + - 当前没有查询 API,这张表目前主要承担“只写不读”的存储角色 + +### 跨模块数据库观察 + +- 当前没有统一的共享 schema;每个组件各自打开自己的 SQLite 文件 +- 没有使用 ORM +- 没有统一 migration 框架 +- 除主键外,没有看到额外索引 +- `poo` 的常规写入和异步 Notion 镜像之间没有事务保证,一致性更接近 best-effort + +## 业务模块拆分 + +### 1. HTTP 外壳 / 应用启动层 + +- 职责: + - 读取配置 + - 设置日志级别 + - 管理 scheduler 生命周期 + - 注册路由 + - 处理优雅退出 +- 主要文件:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:62) +- 依赖: + - 所有业务模块 +- 迁移判断: + - 这部分后续应成为 FastAPI 的 app 装配层 + - 可以在早期先迁为“薄壳” + +### 2. Poo recorder + +- 职责: + - 接收 poo 记录 + - 持久化本地 poo 历史 + - 向 Home Assistant 发布最新状态 sensor + - 触发可选 Home Assistant webhook + - 与 Notion 做同步 +- 主要文件:[`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:50) +- 依赖: + - SQLite + - Home Assistant util + - Notion util + - scheduler +- 迁移判断: + - 这是功能上重要、但耦合也最重的模块 + - 适合在设计上先拆成: + - poo API / service + - Home Assistant 发布适配层 + - legacy Notion sync adapter + +### 3. Location recorder + +- 职责: + - 接收位置更新 + - 持久化人生轨迹点 +- 主要文件:[`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:39) +- 依赖: + - SQLite +- 迁移判断: + - 相对独立 + - 很适合作为优先迁移对象 + - 但需要先明确数值校验规则,因为当前实现会把非法数字静默压成 `0` + +### 4. Home Assistant 命令路由层 + +- 职责: + - 接收命令 envelope + - 根据 target/action 调度 poo、location、ticktick 行为 +- 主要文件:[`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:36) +- 依赖: + - 本地服务端口 + - TickTick util +- 迁移判断: + - 它是外部 automations 的关键契约 + - 应在迁移早中期就被冻结和复刻 + +### 5. TickTick adapter + +- 职责: + - OAuth callback + - project / task REST 操作 + - 任务去重 +- 主要文件:[`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:81) +- 依赖: + - TickTick API + - 可写配置文件 +- 迁移判断: + - 作为内部 adapter 相对独立 + - 复杂度中等,主要难点是 OAuth 和 token 持久化 + +### 6. Home Assistant 出站 client + +- 职责: + - 发布 sensor state + - 触发 webhook +- 主要文件:[`src/util/homeassistantutil/homeassistantutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go:30) +- 依赖: + - Home Assistant API/token +- 迁移判断: + - 小而独立 + - 很适合作为较早迁移的适配层 + +### 7. Notion adapter 与辅助 CLI + +- 职责: + - 读写 Notion table rows + - 维护 poo 相关表格的辅助操作 +- 主要文件: + - [`src/util/notion/notion.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/notion/notion.go:14) + - [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) +- 迁移判断: + - 当前存在,但按已知方向应视为 planned non-migration + - 更适合被标记为 legacy 模块,而不是直接带进 Python 主体 + +### 8. 辅助 helper CLI + +- `src/helper/poo_recorder_helper` + - 用于 Notion 表反转的运维辅助工具 +- `src/helper/location_recorder` + - 当前基本还是脚手架,没有实质业务逻辑 +- 迁移判断: + - 二者都不是后端重构的核心目标 + - `poo_recorder_helper` 应随着 Notion 一并视作 legacy + +## 运行方式与部署形态 + +### 配置方式 + +- 配置文件名:`config.yaml` +- 搜索路径: + - 当前工作目录 + - `$HOME/.config/home-automation` +- 配置加载代码:[`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:65) +- 开启了 `viper.WatchConfig()` + +代码中实际出现的配置项包括: + +- `port` +- `logLevel` +- `notion.token` +- `ticktick.clientId` +- `ticktick.clientSecret` +- `ticktick.redirectUri` +- `ticktick.token` +- `homeassistant.ip` +- `homeassistant.port` +- `homeassistant.authToken` +- `homeassistant.actionTaskProjectId` +- `pooRecorder.tableId` +- `pooRecorder.webhookId` +- `pooRecorder.sensorEntityName` +- `pooRecorder.sensorFriendlyName` +- `pooRecorder.dbPath` +- `locationRecorder.dbPath` + +### 进程模型 + +- 单个二进制,通过 Cobra 子命令 `serve` 启动 +- 监听 `SIGINT` / `SIGTERM` 做优雅退出 +- scheduler 与 HTTP server 在同一进程内运行 +- 路由层使用 `gorilla/mux` + +### 定时任务 + +- 每天 `0 5 * * *` 执行一次 poo records 与 Notion 的同步 +- 定义在 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) + +### 被动接收式接口 + +- 上面列出的入站 REST API +- TickTick OAuth callback endpoint + +### 运行时依赖的外部服务 + +- Home Assistant HTTP API 与 webhook +- TickTick OAuth 与 REST API +- 当前 poo 流程仍依赖 Notion API +- 本地可写文件系统,用于配置文件和 SQLite DB + +### 当前本地 / 服务部署形态 + +- 安装脚本会构建 Go 二进制,并安装到 `$HOME/.local/home-automation-backend` +- 使用 Supervisor 管理进程 +- 生成的 supervisor 配置最终执行 `{binary} serve` +- 参考: + - [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) + - [`helper/home_automation_backend_template.conf`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/home_automation_backend_template.conf:1) + +### 容器化情况 + +- 这一轮代码扫描中没有发现 `Dockerfile` 或 compose 文件 +- 当前部署形态是 supervisor-based,而不是 container-based + +## 测试与文档现状 + +### 测试现状 + +- 只发现一个测试文件:[`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) +- 当前测试覆盖: + - 入站命令 JSON 解码 + - target/action 路由分发 + - 转发到 poo/location handler 的行为 + - TickTick task 创建委托 + - 错误日志和失败路径 +- 当前没有覆盖: + - poo recorder 的 DB 行为 + - location recorder 的 DB 行为 + - TickTick OAuth 流程 + - Home Assistant 出站发布 + - Notion sync 逻辑 + - 启动与配置加载 + - scheduler 行为 + +### 本轮测试执行情况 + +- 我尝试在 `legacy/go-backend/src/` 下执行 `go test ./...` +- 但当前会话环境里没有安装 `go` 命令,因此无法实际运行测试 +- 所以这轮关于测试的判断,基于静态阅读,而不是实际执行结果 + +### 文档现状 + +- 仓库里的 `README.md` 基本只有标题和 badge,内容非常少 +- 没有用户可读的 API 文档 +- 没有 schema 文档 +- 没有 Home Assistant / TickTick 的契约说明文档 +- 没有关于配置项、OAuth 初始化、数据库文件位置的运维文档 + +### 后续最需要补齐的文档 + +- Home Assistant 命令 envelope 与支持的 action +- 各 API 的 request / response 契约 +- TickTick OAuth 初始化与 token 持久化方式 +- 数据库归属、用途与保留策略 +- Notion 下线 / 不迁移说明 + +## 对 Python 重构特别重要的事实 + +- 当前 API 行为整体比较“轻响应、重副作用”,很多成功请求返回的都是空响应体 +- 当前所有入站 API 都没有看到鉴权 +- 当前系统本地真相来源是 SQLite,但 poo 数据还同时与 Notion 同步 +- `notion.token` 现在不是可选项,缺失时服务会在 `initUtil()` 阶段直接退出 +- Home Assistant 命令路由当前是通过本地 HTTP 自调用实现的,而不是直接服务层调用 +- TickTick callback 会改写应用本身使用的 YAML 配置文件 diff --git a/docs/migration-notes.md b/docs/migration-notes.md new file mode 100644 index 0000000..5305177 --- /dev/null +++ b/docs/migration-notes.md @@ -0,0 +1,40 @@ +# Migration Notes + +本文档记录 Python skeleton 阶段的迁移说明,帮助后续继续推进时快速恢复上下文。 + +## 当前阶段完成内容 + +- 建立 FastAPI 应用骨架 +- 建立环境变量配置体系 +- 接入 SQLAlchemy 与 Alembic +- 建立 Jinja2 模板基础 +- 建立 pytest 基础设施 +- 建立 Docker / Compose 基础骨架 +- 建立 OpenAPI 导出脚本 + +## 当前阶段未做内容 + +- 未迁移 TickTick 业务逻辑 +- 未迁移 Home Assistant 业务逻辑 +- 未迁移 poo records +- 未迁移 location / life trajectory +- 未实现真实 OAuth 流程 +- 未做数据迁移 + +## 后续建议顺序 + +建议继续沿用既有迁移文档中的顺序: + +1. 先迁 `location recorder` +2. 再迁 Home Assistant 出站适配层 +3. 再迁 TickTick adapter +4. 再迁 Home Assistant 命令网关 +5. 最后迁 `poo recorder` + +## 开发约束提醒 + +- 保持对当前 Go 外部行为的兼容意识 +- 不要把旧 Python 版本当作设计基线 +- 不要重新引入 Notion 作为 Python 主系统能力 +- 在迁业务模块时,优先补 contract tests + diff --git a/docs/migration-risks.md b/docs/migration-risks.md new file mode 100644 index 0000000..209cde0 --- /dev/null +++ b/docs/migration-risks.md @@ -0,0 +1,238 @@ +# 迁移风险清单 + +本文档列出将当前 Go 后端重构为 Python 时的主要风险点。这里的风险判断,默认都是以“当前 Go 行为需要尽量兼容”为前提。 + +## 最高风险区域 + +### 1. `POST /homeassistant/publish` 存在隐式行为契约 + +风险: + +- 当前 Home Assistant 网关使用 `target`、`action`、`content` 这种 envelope +- `content` 不是标准嵌套 JSON,而是字符串化 payload +- 有些 payload 明显依赖单引号 pseudo-JSON,再在代码中做归一化 + +为什么重要: + +- 如果 Python 版过早改成“严格、干净、标准化的嵌套 JSON”,现有 Home Assistant automations 可能直接失效 + +当前证据: + +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:82) +- 测试见 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:129) + +缓解建议: + +- 在重构前先收集真实线上 payload 样例 +- 第一阶段保留兼容性解析 +- 为所有当前支持的 `target/action` 增加回归测试 + +### 2. TickTick OAuth 与 token 持久化流程 + +风险: + +- OAuth `state` 当前只保存在进程内 +- token 获取后会直接写回 YAML 配置文件 + +为什么重要: + +- Python 重构很容易在不自觉的情况下改变操作流程 +- token 持久化语义一变,可能会带来难排查的鉴权失败 + +当前证据: + +- callback 与配置回写逻辑见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:103) +- 授权 URL 初始化见 [`src/util/ticktickutil/ticktickutil.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/util/ticktickutil/ticktickutil.go:275) + +缓解建议: + +- 在编码前先确定 token storage 方案 +- 保持 callback 契约稳定 +- 在 staging 环境用真实 TickTick app 做端到端验证 + +### 3. Poo recorder 的副作用比 API 表面看起来更复杂 + +风险: + +- `POST /poo/record` 不只是写一条 DB +- 它还会镜像到 Notion、发布 Home Assistant sensor、并且可能触发 Home Assistant webhook + +为什么重要: + +- 即使 Python 版 API 看起来兼容,如果漏掉这些副作用,也会导致真实自动化行为偏差 + +当前证据: + +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:57) +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:97) +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:339) + +缓解建议: + +- 把 endpoint 契约和 side-effect 契约分开写清楚 +- 在 Python 中通过显式 service / adapter 接口承接这些行为 +- 用 mock Home Assistant / TickTick / Notion 的方式做测试 + +### 4. 移除 Notion 会改变当前运行预期 + +风险: + +- Notion 虽然已经被识别为“不计划继续保留”,但它现在并不是边缘代码 +- 它当前参与启动、请求处理以及每日同步 + +为什么重要: + +- 去掉 Notion 会实质改变数据流 +- 也可能影响历史镜像、人工运维方式以及启动要求 + +当前证据: + +- 启动时强依赖 `notion.token`,见 [`src/cmd/serve.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/cmd/serve.go:41) +- 每日同步逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:191) +- helper CLI 见 [`src/helper/poo_recorder_helper/cmd/reverse.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go:21) + +缓解建议: + +- 在文档里显式说明 Notion 将被下线 / 不迁移 +- 先决定是否需要一次性历史导出或回填 +- 确保移除 Notion 后,`pooRecorder.tableId` 和 `notion.token` 不再阻塞服务启动 + +## 中等风险区域 + +### 5. SQLite 兼容性与时间戳格式 + +风险: + +- 当前代码把时间戳以文本形式存储 +- `location` 和 `poo` 两个模块使用的时间格式并不相同 + +为什么重要: + +- Python 重构若擅自统一时间格式,可能会破坏旧 DB 的可兼容读取 + +当前证据: + +- poo 时间戳写入逻辑见 [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:344) +- location 时间戳写入逻辑见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:61) + +缓解建议: + +- 先决定 Python 第一阶段是否直接复用现有 DB 文件 +- 如果要复用,就要保留当前时间戳序列化行为 +- 为数据层建立回归样例 + +### 6. 输入校验行为可能与 FastAPI 默认习惯冲突 + +风险: + +- FastAPI / Pydantic 通常更倾向严格校验 +- 当前 Go 代码的校验行为并不一致: + - 有些接口拒绝 unknown fields + - `location` 数值解析错误会被静默忽略 + - 很多成功响应是空响应体 + +为什么重要: + +- 更“正确”的校验,也可能是破坏兼容性的改动 + +当前证据: + +- 多个 handler 都开启了严格字段检查 +- `location` 的静默 float parsing 见 [`src/components/locationRecorder/locationRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/locationRecorder/locationRecorder.go:54) + +缓解建议: + +- 先明确哪些怪异行为是必须兼容的,哪些可以修正 +- 第一阶段如果要保持兼容,可以在 Python 里用自定义校验逻辑模拟当前行为 + +### 7. 定时任务行为漂移 + +风险: + +- 当前应用内嵌了一个每天 `0 5 * * *` 执行的 Notion 同步任务 + +为什么重要: + +- 如果 Python 版仍保留类似行为,时区、执行时机、幂等性处理差异都可能导致重复或漏同步 + +当前证据: + +- [`src/components/pooRecorder/pooRecorder.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/pooRecorder/pooRecorder.go:180) + +缓解建议: + +- 如果 Notion 被移除,就应有意识地同步移除 scheduler 相关逻辑,并写清楚原因 +- 如果在过渡期暂时保留,就要明确 timezone 与幂等语义 + +### 8. self-HTTP 编排改成 direct service calls 的差异 + +风险: + +- 当前 Home Assistant 网关通过调用 `localhost` 上的本地接口来驱动其它模块 + +为什么重要: + +- 改成直接函数 / service 调用本身是合理的,但可能改变 timeout、错误传播和日志行为 + +当前证据: + +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:88) +- [`src/components/homeassistant/homeassistant.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant.go:115) + +缓解建议: + +- 对外 HTTP 行为保持不变 +- 但在内部重写后,补足状态码和失败语义测试 + +## 风险较低但仍重要的区域 + +### 9. 部署模型变化 + +风险: + +- 当前部署方式是 supervisor-based +- 未来目标是容器化 + +为什么重要: + +- 启动文件路径、配置文件写入位置、token persistence 方式,在容器环境下都可能出问题 + +当前证据: + +- 安装脚本见 [`helper/install.sh`](/home/tianyu/workspace/home-automation/legacy/go-backend/helper/install.sh:45) + +缓解建议: + +- 把运行时状态从镜像内容中解耦 +- 预先定义 DB/config 是否需要挂载 volume +- 在 cutover 前先写清楚 container env vars 与文件挂载约定 + +### 10. 现有测试过少 + +风险: + +- 当前大多数模块没有自动化测试 + +为什么重要: + +- 没有安全网时,重构很容易改坏行为而不自知 + +当前证据: + +- 当前只发现 [`src/components/homeassistant/homeassistant_test.go`](/home/tianyu/workspace/home-automation/legacy/go-backend/src/components/homeassistant/homeassistant_test.go:1) + +缓解建议: + +- 把 contract test 建设视作迁移工作的一部分,而不是迁移后的补票 +- 每迁一个模块,就同步补该模块的测试 + +## 总结 + +这次重构最大的风险,不是“Go 改 Python”本身,而是几个隐藏得很深的行为契约: + +- Home Assistant 命令 payload 的真实格式 +- TickTick 的 OAuth / token 生命周期 +- poo recorder 的一组副作用行为 +- 当前仍活跃、但计划下线的 Notion 耦合 + +只要先把这些契约写清楚、测清楚,再开始 Python 实现,整个重构路线就会可控很多。 diff --git a/docs/python-rewrite-plan.md b/docs/python-rewrite-plan.md new file mode 100644 index 0000000..fcec9c2 --- /dev/null +++ b/docs/python-rewrite-plan.md @@ -0,0 +1,314 @@ +# Python 重构方案 + +本文档基于当前 Go 实现,给出迁移到 Python + FastAPI 的设计输入与建议顺序。本文档只讨论迁移方案,不代表已经开始实现。 + +## 重构原则 + +- 以当前 Go 实现为唯一事实来源 +- 先保持对外行为兼容,再考虑内部清理和优化 +- 明确区分“行为兼容”和“内部实现升级” +- 默认不把 Notion 纳入新的 Python 主版本目标,除非后续重新决策 +- 尽量按模块迁移,并在模块之间建立清晰契约 + +## 目标形态 + +建议的 Python 目标架构: + +- 用 FastAPI 提供 HTTP 路由,并自然生成 OpenAPI +- 业务逻辑按模块拆分到 service layer +- 外部系统对接放到 adapter layer +- SQLite 访问放到 repository layer +- 配置采用显式 settings model +- scheduler 是否内嵌在应用进程内,需要在启动语义明确后再决定 +- 仅保留极轻量的服务端页面,用于 OAuth 跳转或简单配置 + +## 建议的 Python 模块边界 + +### 应用外壳层 + +- 职责: + - 读取 settings + - 依赖注入与对象装配 + - 路由注册 + - lifespan hooks + - scheduler 启停 +- 在 FastAPI 中可对应: + - `main.py` 或 app factory + - settings 类 + +### Poo 领域模块 + +- 职责: + - 校验 poo record 输入 + - 持久化 poo record + - 查询 latest poo + - 通过接口触发外部副作用 +- 建议把副作用依赖抽象为端口: + - `PooRepository` + - `HomeAssistantPublisher` + - `HomeAssistantWebhookClient` + - 如有需要,可保留临时 `LegacyPooMirror` 作为 Notion 过渡适配器 + +### Location 领域模块 + +- 职责: + - 校验并持久化位置点 +- 可保持简洁: + - `LocationRepository` + - `LocationService` + +### Home Assistant 命令网关 + +- 职责: + - 暴露 `/homeassistant/publish` + - 解析 `target/action/content` + - 将命令分发到内部服务 +- 兼容性注意: + - 第一阶段应保留当前 `content` 的处理习惯,包括对字符串 payload 的兼容解析 + +### TickTick 集成模块 + +- 职责: + - OAuth start / callback + - token 存储 + - task 查询 + - 去重 + - task 创建 +- 建议: + - 把 token persistence 抽象成独立能力,而不是把“改写配置文件”直接塞进业务逻辑 + +### Home Assistant 出站适配层 + +- 职责: + - 发布 sensor state + - 触发 webhook +- 该层小而独立,适合较早迁移 + +### Legacy Notion 适配层 + +- 职责: + - 只在分析或过渡阶段表示当前行为 +- 默认建议: + - 不放入 Python 第一版正式目标 + - 如 cutover 期间确有需要,可以 feature flag 或独立迁移工具的方式暂存 + +## 实现前需要冻结的兼容契约 + +在正式编码前,建议先把以下当前行为写成明确契约: + +### API 契约 + +- `POST /poo/record` 的请求字段与当前“成功时空响应体”的行为 +- `POST /location/record` 的请求字段与数值解析行为 +- `POST /homeassistant/publish` 的 envelope 格式与支持的 `target/action` +- `GET /ticktick/auth/code` 的成功 / 失败语义 + +### 副作用契约 + +- `POST /poo/record` 在什么时机会发布 Home Assistant sensor +- `POST /poo/record` 在什么条件下会触发 Home Assistant webhook +- Home Assistant 消息如何映射为 TickTick task title 与 due date +- Home Assistant sensor payload 的结构 + +### 持久化契约 + +- 当前 SQLite 表名与主键 +- 当前磁盘上的时间戳格式 +- Python 第一阶段是否直接复用现有 DB 文件,还是做显式迁移 + +## 建议的迁移决策 + +### 决策 1:第一阶段保持对外 API 形状不变 + +原因: + +- 当前 API 面很小 +- 保持兼容能显著降低切换风险 +- 即使保持兼容,FastAPI 仍然可以生成 OpenAPI 文档 + +### 决策 2:把内部 self-HTTP 改成直接服务调用 + +原因: + +- 当前 Go 代码中的 `localhost` 自调用,本质上是内部编排手段 +- 这不是一个必须暴露给外部的契约 +- Python 版改为直接函数 / service 调用,可以提升清晰度和可测试性 + +### 决策 3:先继续使用 SQLite + +原因: + +- 当前系统已经使用 SQLite +- 数据模型规模很小 +- PostgreSQL 更适合作为 parity 之后的下一阶段演进 + +### 决策 4:默认不迁 Notion,但要明确记录影响 + +原因: + +- 你已经明确表示 Notion 很可能不继续保留 +- 当前 Notion 不是“代码里有但没在用”,而是真正参与运行逻辑 +- 所以不能静默删除,而要在方案中写清楚删掉后有什么影响 + +### 决策 5:把 token / auth persistence 做成显式设计 + +原因: + +- 当前 TickTick token 处理虽然可用,但运维上比较脆弱 +- Python 重构是一个把这件事规范化的机会 + +## 建议迁移顺序 + +### Phase 0:盘点与契约确认 + +- 完成当前系统 inventory +- 确认哪些当前行为是“必须兼容的契约”,哪些只是历史偶然实现 +- 明确把 Notion 标为 non-migration scope,除非后续重新决定 + +### Phase 1:Python 骨架与通用基础设施 + +- 建立 FastAPI app shell +- 定义 settings / config model +- 定义日志方案 +- 定义 SQLite 访问方式 +- 定义测试框架与 fixture 策略 +- 定义 OpenAPI 生成与导出方式 + +这一阶段不需要大量迁移业务逻辑,只要搭好后续模块可持续迁入的基础即可。 + +### Phase 2:先迁最独立、最稳定的业务模块 + +推荐优先迁移:`location recorder` + +原因: + +- 独立 SQLite 表 +- 没有复杂外部副作用 +- 没有 OAuth +- 没有 scheduler + +这一阶段的交付物可以包括: + +- `POST /location/record` +- 与现有 SQLite 兼容的写入逻辑 +- 校验与 repository 的单元测试 +- 基于临时 SQLite 的 integration test + +### Phase 3:迁移 Home Assistant 出站适配层 + +原因: + +- 功能面小 +- 能为后面的 poo 迁移做铺垫 + +这一阶段的交付物可以包括: + +- sensor publish client +- webhook trigger client +- 针对请求格式与错误处理的 mock tests + +### Phase 4:迁移 TickTick adapter + +原因: + +- 相对自洽 +- 在完成 Home Assistant 命令网关前就需要它 + +这一阶段的交付物可以包括: + +- OAuth callback endpoint +- token persistence abstraction +- task 创建与去重行为 +- 基于 mock HTTP 的集成式测试 + +### Phase 5:迁移 Home Assistant 命令网关 + +原因: + +- 这是外部 automations 的核心编排入口 +- 在 location 与 TickTick adapter 准备好后,网关迁移会顺很多 + +这一阶段的交付物可以包括: + +- `/homeassistant/publish` +- 兼容当前 `target/action` 的分发逻辑 +- 用进程内 service 调用替代 self-HTTP +- 把现有 Go 测试场景迁成 Python contract tests + +### Phase 6:迁移 poo recorder 核心,但默认不带 Notion + +原因: + +- 这是最复杂的模块 +- 它既有本地 DB,又有 Home Assistant 副作用,当前还耦合 Notion + +建议拆成两个子阶段: + +- phase 6a: + - 本地 poo DB + - latest poo 查询 + - sensor publish + - 可选 webhook trigger + - `/poo/record` + - `/poo/latest` + +- phase 6b: + - 如果 cutover 期间必须保留旧逻辑,再做一个临时 legacy Notion 兼容层 + +### Phase 7:运维加固与切换验证 + +- 做 Go / Python 路由级契约比对 +- 用现有 SQLite 文件或其副本做兼容验证 +- 在 staging 环境手动验证 TickTick OAuth +- 用真实 Home Assistant automation payload 做验证 +- 导出 OpenAPI YAML +- 再补容器化与部署方案 + +## 哪些模块适合先迁,哪些适合后迁 + +### 适合优先迁移 + +- location recorder +- Home Assistant 出站 client +- TickTick adapter + +### 更适合后迁 + +- Home Assistant 命令网关 +- poo recorder 核心 + +### 现状存在,但建议不迁 + +- Notion sync adapter +- `poo_recorder_helper` 的 Notion 表反转 CLI +- `location_recorder` helper CLI 脚手架 + +## 建议的验证策略 + +### Contract tests + +- 基于当前 Go 行为建立 request / response fixtures +- 先把现有 `homeassistant` 测试案例迁成 Python +- 补上 `poo` 与 `location` API 的契约测试 + +### Integration tests + +- 每个模块使用临时 SQLite DB +- Home Assistant 与 TickTick 出站流量通过 mock HTTP 替代 +- 若仍保留 scheduler,则为其补定时行为测试 + +### 手工 staging 验证 + +- TickTick OAuth callback +- Home Assistant sensor 更新 +- Home Assistant webhook 触发 +- 当前真实自动化 payload 样例 + +## 开始实现前仍需明确的问题 + +- Python 第一阶段是否还要保留“缺少 `notion.token` 就启动失败”的行为,还是直接把 Notion 变成可关闭能力? +- `POST /location/record` 是否要继续保留“非法数字静默变成 0”的兼容行为? +- TickTick token 在第一阶段是否继续写回 YAML,还是立即切到独立 token store? +- 当前 Home Assistant automations 是否真实依赖 `content` 中的单引号 pseudo-JSON? + +这些问题不影响当前 inventory,但会影响第一阶段“兼容到什么程度”的具体定义。 diff --git a/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..e5b1b42 --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,18 @@ +# Legacy Code + +这个目录用于收纳 Python 重构开始之前就已存在的旧实现与配套资产,方便在重构完成后整块删除。 + +当前已迁入: + +- `go-backend/src/` + - 旧 Go 后端实现 +- `go-backend/helper/` + - 旧 Go 部署与辅助脚本 +- `go-backend/.github/workflows/` + - 旧 Go 版本对应的 GitHub Actions workflows + +原则上: + +- 新的 Python 实现继续在仓库根目录的 `app/`、`tests/`、`alembic/` 等目录演进 +- 旧 Go 代码只作为迁移参考,不再作为新实现的结构基础 +- 当 Python 重构完成并验证稳定后,可以考虑整块删除 `legacy/go-backend/` diff --git a/.github/workflows/nightly.yml b/legacy/go-backend/.github/workflows/nightly.yml similarity index 100% rename from .github/workflows/nightly.yml rename to legacy/go-backend/.github/workflows/nightly.yml diff --git a/.github/workflows/short-tests.yml b/legacy/go-backend/.github/workflows/short-tests.yml similarity index 100% rename from .github/workflows/short-tests.yml rename to legacy/go-backend/.github/workflows/short-tests.yml diff --git a/helper/home_automation_backend_template.conf b/legacy/go-backend/helper/home_automation_backend_template.conf similarity index 100% rename from helper/home_automation_backend_template.conf rename to legacy/go-backend/helper/home_automation_backend_template.conf diff --git a/helper/install.sh b/legacy/go-backend/helper/install.sh similarity index 100% rename from helper/install.sh rename to legacy/go-backend/helper/install.sh diff --git a/src/LICENSE b/legacy/go-backend/src/LICENSE similarity index 100% rename from src/LICENSE rename to legacy/go-backend/src/LICENSE diff --git a/src/cmd/root.go b/legacy/go-backend/src/cmd/root.go similarity index 100% rename from src/cmd/root.go rename to legacy/go-backend/src/cmd/root.go diff --git a/src/cmd/serve.go b/legacy/go-backend/src/cmd/serve.go similarity index 100% rename from src/cmd/serve.go rename to legacy/go-backend/src/cmd/serve.go diff --git a/src/components/homeassistant/homeassistant.go b/legacy/go-backend/src/components/homeassistant/homeassistant.go similarity index 100% rename from src/components/homeassistant/homeassistant.go rename to legacy/go-backend/src/components/homeassistant/homeassistant.go diff --git a/src/components/homeassistant/homeassistant_test.go b/legacy/go-backend/src/components/homeassistant/homeassistant_test.go similarity index 100% rename from src/components/homeassistant/homeassistant_test.go rename to legacy/go-backend/src/components/homeassistant/homeassistant_test.go diff --git a/src/components/locationRecorder/locationRecorder.go b/legacy/go-backend/src/components/locationRecorder/locationRecorder.go similarity index 100% rename from src/components/locationRecorder/locationRecorder.go rename to legacy/go-backend/src/components/locationRecorder/locationRecorder.go diff --git a/src/components/pooRecorder/pooRecorder.go b/legacy/go-backend/src/components/pooRecorder/pooRecorder.go similarity index 100% rename from src/components/pooRecorder/pooRecorder.go rename to legacy/go-backend/src/components/pooRecorder/pooRecorder.go diff --git a/src/go.mod b/legacy/go-backend/src/go.mod similarity index 100% rename from src/go.mod rename to legacy/go-backend/src/go.mod diff --git a/src/go.sum b/legacy/go-backend/src/go.sum similarity index 100% rename from src/go.sum rename to legacy/go-backend/src/go.sum diff --git a/src/helper/location_recorder/LICENSE b/legacy/go-backend/src/helper/location_recorder/LICENSE similarity index 100% rename from src/helper/location_recorder/LICENSE rename to legacy/go-backend/src/helper/location_recorder/LICENSE diff --git a/src/helper/location_recorder/cmd/addgpx.go b/legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go similarity index 100% rename from src/helper/location_recorder/cmd/addgpx.go rename to legacy/go-backend/src/helper/location_recorder/cmd/addgpx.go diff --git a/src/helper/location_recorder/cmd/root.go b/legacy/go-backend/src/helper/location_recorder/cmd/root.go similarity index 100% rename from src/helper/location_recorder/cmd/root.go rename to legacy/go-backend/src/helper/location_recorder/cmd/root.go diff --git a/src/helper/location_recorder/main.go b/legacy/go-backend/src/helper/location_recorder/main.go similarity index 100% rename from src/helper/location_recorder/main.go rename to legacy/go-backend/src/helper/location_recorder/main.go diff --git a/src/helper/poo_recorder_helper/LICENSE b/legacy/go-backend/src/helper/poo_recorder_helper/LICENSE similarity index 100% rename from src/helper/poo_recorder_helper/LICENSE rename to legacy/go-backend/src/helper/poo_recorder_helper/LICENSE diff --git a/src/helper/poo_recorder_helper/cmd/reverse.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go similarity index 100% rename from src/helper/poo_recorder_helper/cmd/reverse.go rename to legacy/go-backend/src/helper/poo_recorder_helper/cmd/reverse.go diff --git a/src/helper/poo_recorder_helper/cmd/root.go b/legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go similarity index 100% rename from src/helper/poo_recorder_helper/cmd/root.go rename to legacy/go-backend/src/helper/poo_recorder_helper/cmd/root.go diff --git a/src/helper/poo_recorder_helper/main.go b/legacy/go-backend/src/helper/poo_recorder_helper/main.go similarity index 100% rename from src/helper/poo_recorder_helper/main.go rename to legacy/go-backend/src/helper/poo_recorder_helper/main.go diff --git a/src/main.go b/legacy/go-backend/src/main.go similarity index 100% rename from src/main.go rename to legacy/go-backend/src/main.go diff --git a/src/util/homeassistantutil/homeassistantutil.go b/legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go similarity index 100% rename from src/util/homeassistantutil/homeassistantutil.go rename to legacy/go-backend/src/util/homeassistantutil/homeassistantutil.go diff --git a/src/util/notion/notion.go b/legacy/go-backend/src/util/notion/notion.go similarity index 100% rename from src/util/notion/notion.go rename to legacy/go-backend/src/util/notion/notion.go diff --git a/src/util/ticktickutil/ticktickutil.go b/legacy/go-backend/src/util/ticktickutil/ticktickutil.go similarity index 100% rename from src/util/ticktickutil/ticktickutil.go rename to legacy/go-backend/src/util/ticktickutil/ticktickutil.go diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3eb7047 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "home-automation-python" +version = "0.1.0" +description = "Python rewrite skeleton for the home automation backend." +readme = "README.md" +requires-python = ">=3.11" + +[tool.setuptools] +packages = [ + "app", + "app.api", + "app.api.routes", + "app.integrations", + "app.models", + "app.schemas", + "app.services", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] + +[tool.ruff] +line-length = 100 diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..ce81f3c --- /dev/null +++ b/requirements.in @@ -0,0 +1,8 @@ +alembic>=1.14,<2.0 +fastapi>=0.115,<0.116 +jinja2>=3.1,<4.0 +pydantic-settings>=2.6,<3.0 +python-multipart>=0.0.12,<1.0 +pyyaml>=6.0,<7.0 +sqlalchemy>=2.0,<3.0 +uvicorn[standard]>=0.32,<1.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c95bd7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,78 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile requirements.in +# +alembic==1.18.4 + # via -r requirements.in +annotated-types==0.7.0 + # via pydantic +anyio==4.13.0 + # via + # starlette + # watchfiles +click==8.3.2 + # via uvicorn +fastapi==0.115.14 + # via -r requirements.in +greenlet==3.4.0 + # via sqlalchemy +h11==0.16.0 + # via uvicorn +httptools==0.7.1 + # via uvicorn +idna==3.11 + # via anyio +jinja2==3.1.6 + # via -r requirements.in +mako==1.3.11 + # via alembic +markupsafe==3.0.3 + # via + # jinja2 + # mako +pydantic==2.13.2 + # via + # fastapi + # pydantic-settings +pydantic-core==2.46.2 + # via pydantic +pydantic-settings==2.13.1 + # via -r requirements.in +python-dotenv==1.2.2 + # via + # pydantic-settings + # uvicorn +python-multipart==0.0.26 + # via -r requirements.in +pyyaml==6.0.3 + # via + # -r requirements.in + # uvicorn +sqlalchemy==2.0.49 + # via + # -r requirements.in + # alembic +starlette==0.46.2 + # via fastapi +typing-extensions==4.15.0 + # via + # alembic + # fastapi + # pydantic + # pydantic-core + # sqlalchemy + # typing-inspection +typing-inspection==0.4.2 + # via + # pydantic + # pydantic-settings +uvicorn[standard]==0.44.0 + # via -r requirements.in +uvloop==0.22.1 + # via uvicorn +watchfiles==1.1.1 + # via uvicorn +websockets==16.0 + # via uvicorn diff --git a/scripts/export_openapi.py b/scripts/export_openapi.py new file mode 100644 index 0000000..1adb17a --- /dev/null +++ b/scripts/export_openapi.py @@ -0,0 +1,27 @@ +from pathlib import Path + +import yaml + +from app.main import create_app + + +def main() -> None: + app = create_app() + output_dir = Path("openapi") + output_dir.mkdir(parents=True, exist_ok=True) + + schema = app.openapi() + + json_path = output_dir / "openapi.json" + yaml_path = output_dir / "openapi.yaml" + + json_path.write_text(__import__("json").dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8") + yaml_path.write_text(yaml.safe_dump(schema, allow_unicode=True, sort_keys=False), encoding="utf-8") + + print(f"Wrote {json_path}") + print(f"Wrote {yaml_path}") + + +if __name__ == "__main__": + main() + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4f8484c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,15 @@ +import pytest +from fastapi.testclient import TestClient + +from app.main import create_app + + +@pytest.fixture +def app(): + return create_app() + + +@pytest.fixture +def client(app): + with TestClient(app) as test_client: + yield test_client diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..b6140f2 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,13 @@ +from fastapi.testclient import TestClient + + +def test_app_starts(client: TestClient) -> None: + response = client.get("/") + assert response.status_code == 200 + + +def test_status_endpoint(client: TestClient) -> None: + response = client.get("/status") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +