Bootstrap Python rewrite skeleton
This commit is contained in:
1 parent
7818a3fb44
commit
31390882ef
72 files changed
+2272
-61
No files matched your search
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.pytest_cache
|
||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
data
|
||||||
|
openapi
|
||||||
|
src
|
||||||
|
|
||||||
@@ -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=
|
||||||
|
|
||||||
+5
-34
@@ -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
|
.env
|
||||||
|
|
||||||
temp_data/
|
|
||||||
|
|
||||||
# py file for branch switching
|
|
||||||
.venv
|
|
||||||
__pycache__/
|
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
config.yaml
|
.venv/
|
||||||
bin/
|
__pycache__/
|
||||||
*.db
|
*.pyc
|
||||||
|
data/
|
||||||
|
openapi/
|
||||||
|
|
||||||
cover.html
|
|
||||||
Vendored
+11
-26
@@ -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",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "Launch Python App",
|
||||||
"type": "go",
|
"type": "debugpy",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"module": "uvicorn",
|
||||||
"program": "${workspaceFolder}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Launch Poo Reverse",
|
|
||||||
"type": "go",
|
|
||||||
"request": "launch",
|
|
||||||
"mode": "auto",
|
|
||||||
"program": "${workspaceFolder}/src/helper/poo_recorder_helper/main.go",
|
|
||||||
"args": [
|
"args": [
|
||||||
"reverse"
|
"app.main:app",
|
||||||
]
|
"--reload",
|
||||||
},
|
"--host",
|
||||||
{
|
"0.0.0.0",
|
||||||
"name": "Launch Home Automation",
|
"--port",
|
||||||
"type": "go",
|
"8000"
|
||||||
"request": "launch",
|
],
|
||||||
"mode": "auto",
|
"jinja": true
|
||||||
"program": "${workspaceFolder}/src/main.go",
|
|
||||||
"args": [
|
|
||||||
"serve"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+20
@@ -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"]
|
||||||
@@ -1,3 +1,177 @@
|
|||||||
# Home Automation Backend
|
# Home Automation Backend
|
||||||
|
|
||||||

|
这是当前 `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)
|
||||||
+37
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
This directory contains the Alembic migration environment for the Python rewrite skeleton.
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Application package for the Python rewrite skeleton."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""API package."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Route modules."""
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""External integration placeholders for future migration."""
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
+45
@@ -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()
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""SQLAlchemy models package."""
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from app.db import Base
|
||||||
|
|
||||||
|
__all__ = ["Base"]
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Pydantic schemas package."""
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class StatusResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Service layer package."""
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='styles.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="shell">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ app_name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<section class="panel">
|
||||||
|
<p class="eyebrow">Python Rewrite Skeleton</p>
|
||||||
|
<h1>{{ app_name }}</h1>
|
||||||
|
<p class="lead">
|
||||||
|
这是当前 Go 后端的 Python 重构基础骨架。此阶段仅提供应用入口、配置、数据库、
|
||||||
|
测试、模板和容器化基础,不包含业务逻辑迁移。
|
||||||
|
</p>
|
||||||
|
<dl class="meta">
|
||||||
|
<div>
|
||||||
|
<dt>运行环境</dt>
|
||||||
|
<dd>{{ app_env }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>健康检查</dt>
|
||||||
|
<dd><a href="/status">/status</a></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>OpenAPI</dt>
|
||||||
|
<dd><a href="/docs">/docs</a></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Notion</dt>
|
||||||
|
<dd>{{ notion_status }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-r requirements.in
|
||||||
|
|
||||||
|
httpx>=0.28,<1.0
|
||||||
|
pip-tools>=7.4,<8.0
|
||||||
|
pytest>=8.3,<9.0
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 骨架中保留它。
|
||||||
|
|
||||||
@@ -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 配置文件
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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 实现,整个重构路线就会可控很多。
|
||||||
@@ -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,但会影响第一阶段“兼容到什么程度”的具体定义。
|
||||||
@@ -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/`
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
|
|
||||||
Reference in new issue
Block a user