Files
tliu93 8b8bd9f38f
test / pytest (push) Successful in 1m34s
Add Alembic migration foundation
2026-06-01 16:02:43 +02:00

301 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 仓库简报 / Repository Brief — 2026 搬家助手 (Moving Helper)
> 面向「下一轮改动」前的快速理解文档。中英双语对照。
> A bilingual orientation doc to read before the next round of changes.
>
> 对应版本 / Snapshot: `main` @ `b9b6583`(撰写时 / at time of writing
---
## 1. 一句话定位 / In One Sentence
**中文:** 一个轻量的、面向**可信家庭内网**的搬家装箱记录工具:记录「有哪些箱子、每个箱子里有什么物品、容器型物品里又装了什么」,支持单图、全局搜索,并以 Docker 长期运行。
**EN:** A lightweight, **trusted-home-LAN** moving inventory tool: track *which boxes exist, what items each box holds, and what sub-items sit inside container-type items*. Supports one image per record, global search, and runs long-term via Docker.
设计取向 / Design stance:小而稳、易于几个月后回来继续扩展;**不是**企业平台、**不**追求复杂运维。
Small, stable, easy to pick back up months later; **not** an enterprise platform.
---
## 2. 技术栈 / Tech Stack
| 层 / Layer | 选型 / Choice | 版本 / Version |
| --- | --- | --- |
| Web 框架 / Framework | FastAPI | 0.116.1 |
| ASGI 服务器 / Server | Uvicorn (`[standard]`) | 0.35.0 |
| 模板 / Templating | Jinja2(服务端渲染 SSR | 3.1.6 |
| ORM | SQLAlchemy 2.x`Mapped` / `mapped_column` 风格) | 2.0.43 |
| 数据库 / DB | SQLite(文件库 / file-based | — |
| 表单 / Forms | python-multipart | 0.0.20 |
| 图片处理 / Images | PillowHEIC 时可选 `pillow_heif` / `sips` 兜底) | 11.2.1 |
| HTTP 客户端 / Client | requests(仅 Notion 导入用) | 2.32.3 |
| 测试 / Tests | pytest + Starlette `TestClient`(httpx) | 8.4.1 / 0.28.1 |
| 部署 / Deploy | Docker / Docker Compose + nginx 反代 | — |
**没有前端构建链 / No frontend build chain**:纯 SSR + 一个 `style.css` + `base.html` 里的少量原生 JS。无 npm / Node / 打包器。
Pure SSR + one `style.css` + a little vanilla JS inline in `base.html`. No npm/Node/bundler.
---
## 3. 目录结构 / Project Layout
```text
.
├── app/
│ ├── __init__.py
│ ├── config.py # 环境变量配置 (Settings dataclass)
│ ├── db.py # SQLAlchemy engine/session、init_db、SQLite 轻量迁移
│ ├── images.py # 图片处理管线 (Pillow + HEIC 兜底)
│ ├── main.py # 所有路由 + 应用工厂 create_app() ← 核心
│ ├── models.py # Box / Item / SubItem 三个 ORM 模型
│ ├── notion_import.py # 一次性 Notion 导入的解析/写入逻辑
│ ├── static/ # style.css, manifest, service-worker.js, PWA 图标
│ └── templates/ # Jinja2 模板 (boxes/ items/ subitems/ search/ + base.html)
├── scripts/
│ ├── install.sh # 一键安装:渲染 nginx/backup/compose + cron
│ ├── deploy.sh # 仓库内的轻量更新脚本 (git pull + compose up)
│ ├── backup_db.sh # 备份脚本模板 (占位符由 install.sh 渲染)
│ ├── import_notion.py # Notion 导入 CLI 入口
│ └── nginx/moving-helper.nginx.template
├── tests/ # pytesttest_app.py (~74) + test_notion_import.py (9)
├── data/ # 运行期 SQLite (data/app.db),已 gitignore
├── .github/workflows/ # test.yml (CI) + docker-image.yml (CD)
├── Dockerfile, docker-compose.yml, .dockerignore
├── .env.example # 部署配置示例(被 shell 脚本 source
├── pytest.ini, requirements.txt, README.md
└── docs/ # ← 本文档所在
```
**关键入口 / Key entry point** `app/main.py` 里的 `create_app()` 注册了**全部**路由。整个后端逻辑几乎都在这一个文件里。
Almost all backend logic lives in the single `create_app()` factory in `app/main.py`.
---
## 4. 数据模型 / Data Model
固定**三级层次**,不是无限树 / A fixed **3-level hierarchy**, not an arbitrary tree
```text
Box (顶层容器 / top container:纸箱、行李箱…)
└── Item (箱子里的物品 / an item in the box)
└── SubItem (仅容器型 Item 才有 / only under container items)
```
定义于 `app/models.py`
| 模型 / Model | 表 / Table | 关键字段 / Key fields | 关系 / Relations |
| --- | --- | --- | --- |
| `Box` | `boxes` | `name`, `note`, `room`, `status` + 图片字段 + 时间戳 | `items` → 多个 Item`cascade="all, delete-orphan"` |
| `Item` | `items` | `box_id`(FK), `name`, `note`, `quantity`, `is_container` + 图片字段 + 时间戳 | 属于一个 `Box``subitems` → 多个 SubItem(级联删除) |
| `SubItem` | `subitems` | `parent_item_id`(FK), `name`, `note`, `quantity` + 图片字段 + 时间戳 | 属于一个 `Item` |
**核心规则 / Core rules**
- 只有 `Item.is_container == True` 的物品才允许拥有 `SubItem`;非容器去建子物品会返回 **400**`_require_container_item`)。
Only container items may hold sub-items; otherwise the API returns **400**.
- 在更新 Item 时,若取消勾选 `is_container`,会**清空其所有 SubItem**`item.subitems.clear()``main.py:454`)。
Un-checking `is_container` on update **clears all sub-items**.
- 删除级联:删 Box → 删其 Item → 删其 SubItem,由 ORM `cascade` + 外键 `ondelete="CASCADE"` 双重保障(SQLite 还启用了 `PRAGMA foreign_keys=ON`)。
Delete cascades top-down, enforced both by ORM cascade and FK `ondelete`.
**图片字段(每个模型都有同一组)/ Image fields (same set on every model)**
`image_blob` (BLOB)、`image_mime_type``image_width``image_height`
→ 图片直接以二进制存进 SQLite,**不落地为文件**。Images are stored **inline as BLOBs in SQLite**, not as files.
时间戳 / Timestamps`created_at``updated_at`UTC`updated_at``onupdate`)。
---
## 5. 路由总览 / Route Map
全部在 `app/main.py`。POST 后统一 **303 See Other** 重定向(避免重复提交)。
All in `app/main.py`. POSTs redirect with **303 See Other**.
| 方法 路径 / Method Path | 作用 / Purpose |
| --- | --- |
| `GET /` | 302 跳转到 `/boxes` |
| `GET /manifest.webmanifest`, `GET /service-worker.js` | PWA 资源(从根路径返回) |
| `GET /search?q=` | 全局搜索页 |
| `GET /boxes` | 箱子列表 + 概览统计 |
| `GET /boxes/new` · `POST /boxes` | 新建箱子表单 / 提交 |
| `GET /boxes/{id}` | 箱子详情(含其 Item 列表) |
| `GET /boxes/{id}/edit` · `POST /boxes/{id}/update` | 编辑 / 保存 |
| `POST /boxes/{id}/delete` | 删除箱子 |
| `GET /boxes/{id}/image` · `POST /boxes/{id}/image/delete` | 取图 / 删图 |
| `GET /boxes/{id}/items/new` · `POST /boxes/{id}/items` | 在箱子下新建物品 |
| `GET /items/{id}` | 物品详情(容器型则含 SubItem 列表) |
| `GET /items/{id}/edit` · `POST /items/{id}/update` · `POST /items/{id}/delete` | 编辑 / 保存 / 删除 |
| `GET /items/{id}/image` · `POST /items/{id}/image/delete` | 取图 / 删图 |
| `GET /items/{id}/subitems/new` · `POST /items/{id}/subitems` | 在容器型物品下新建子物品 |
| `GET /subitems/{id}/edit` · `POST /subitems/{id}/update` · `POST /subitems/{id}/delete` | 编辑 / 保存 / 删除 |
| `GET /subitems/{id}/image` · `POST /subitems/{id}/image/delete` | 取图 / 删图 |
**重定向行为细节 / Redirect nuances**`main.py` 创建逻辑):
- 创建物品时,若点「保存并添加下一个」(`submit_action=save_and_add_next`) → 回到新建表单;若是容器型 → 跳到物品详情;否则 → 回箱子详情。
On create: *save & add next* → back to new form; container → item detail; else → box detail.
- 子物品的「保存并添加下一个」同理回到子物品新建表单。
Sub-item *save & add next* returns to its new-form too.
> 没有 JSON API / no JSON API:全部返回 HTML(图片路由返回二进制)。FastAPI 的自动 `/docs` 仍可用,但业务路由均是表单驱动的 SSR。
---
## 6. 图片处理管线 / Image Pipeline (`app/images.py`)
每次上传都会经过 `process_upload()``_prepare_image()` 统一处理:
Every upload is normalized through `process_upload()``_prepare_image()`:
1. 读取字节,空内容 → 400;非法图片 → 400。Read bytes; empty/invalid → 400.
2. **按 EXIF 方向矫正**`ImageOps.exif_transpose`),再处理。Apply EXIF orientation first.
3. 去元数据并转 RGB`RGBA/LA` 贴白底、`P` 转 RGB)。Strip metadata, flatten to RGB.
4. **最长边缩放到 ≤ 1600px**`thumbnail`)。Downscale longest side to ≤ 1600px.
5. 存为 **JPEG,质量 80`optimize=True`**。Save as JPEG q80.
6. 写入 `image_blob` + 记录 mime / 宽 / 高。Store blob + dimensions.
**HEIC/HEIF 兜底 / fallback** 先尝试 `pillow_heif`(若已安装);否则在 macOS 上用 `sips` 转 JPEG;都不行则返回中文错误提示让用户先转格式。
Tries `pillow_heif`, then macOS `sips`, else a clear error asking to convert first.
> 注意 / Note`pillow_heif` **不在** `requirements.txt` 里,所以默认环境 HEIC 依赖系统 `sips`(仅 macOS)。Linux 容器里上传 HEIC 会得到「请先转换」的提示。
每个对象**最多一张图**,支持上传 / 替换 / 删除,不支持多图。
One image per object; upload/replace/delete; no multi-image.
---
## 7. 全局搜索 / Global Search (`_build_search_results`, `main.py`)
- `GET /search?q=关键词`,对 `Box / Item / SubItem``name``note`**SQLite `LOWER(...) LIKE %q%`** 模糊匹配(大小写不敏感)。
Case-insensitive `LIKE` over `name` + `note` across all three types.
- 结果带:类型标签、归属路径(Item 显示所属 BoxSubItem 显示所属 Item + Box)、若有图则带缩略图链接。
Results include type, location path, and a thumbnail link if an image exists.
- 无外部搜索引擎、无全文索引。No external search engine / full-text index.
`/boxes` 概览页另有统计(`_build_boxes_overview_summary`):箱子数、物品数(含/不含子物品)、每箱平均物品数、每容器型 Item 平均子物品数。
---
## 8. PWA 支持 / PWA Support
最小可安装 PWA**不改 SSR 结构** / minimal installable PWA without changing SSR
- 根路径提供 `manifest.webmanifest`(正确 mime)和 `service-worker.js`
- `base.html` 注入 theme-color、apple-touch-icon、安装相关 meta,并注册 service worker。
- 图标:180apple-touch)、192、512、512-maskable,位于 `app/static/icons/`
**当前 service worker 仅做 `skipWaiting` + `clients.claim()`,没有任何缓存/离线能力。**
The service worker only claims clients — **no caching, no offline** yet.
`base.html` 里的原生 JS 还实现了:可点击卡片(`.clickable-card[data-href]`,含键盘 Enter/Space 支持)、表单内回车跳到下一个字段。
---
## 9. 配置与环境变量 / Configuration
**应用运行时 / App runtime**`app/config.py``Settings` dataclass):
| 变量 / Var | 默认 / Default | 说明 |
| --- | --- | --- |
| `DATABASE_URL` | `sqlite:///./data/app.db` | 数据库连接串 |
| `HOST` | `0.0.0.0` | (定义了但 uvicorn 在 CMD 里写死) |
| `PORT` | `10000` | 同上 |
**部署时 / Deploy-time**`.env`,被 shell 脚本 `source`**非**应用直接读取):
`HOST_DOMAIN``SSL_PATH``APP_DIR``BACKUP_DIR``BACKUP_REMOTE``APP_PORT``DATA_DIR``DATABASE_URL``COMPOSE_PROJECT_NAME`。详见 `.env.example`
约定 / Conventions:容器内固定监听 `0.0.0.0:10000``APP_PORT` 只控制宿主机暴露端口;SQLite 固定写 `/app/data/app.db`(容器内)。
---
## 10. 数据库初始化与迁移 / DB Init & Migrations (`app/migrate.py` + `app/db.py`)
- **Alembic 接管 schema**:迁移系统由 Alembic 管理(`alembic.ini` + `migrations/`),V1 baseline 等于当前三表 schema。
Alembic owns schema creation and changes (`alembic.ini` + `migrations/`); V1 baseline equals the current three-table schema.
- **迁移与启动分离 / Migrations separated from startup**
- `init_db()``app/db.py`)在 FastAPI lifespan 启动时调用 `verify_schema_is_current()`,只做**只读校验**——检查 DB 是否在 `head`,不一致则 **fail-close**(拒绝启动、不执行任何 DDL)。
`init_db()` calls `verify_schema_is_current()` at startup — read-only check, fails closed on mismatch, no DDL.
- 实际迁移由独立幂等命令 `python -m app.migrate``app/migrate.py`)执行:空库建表、老库认领(stamp V1 → upgrade head)、已在 head 则空操作。老库 schema 不匹配则 fail-close 不改动。
Actual migration via standalone idempotent command `python -m app.migrate`: fresh DB → create, matching existing → adopt, already-at-head → no-op, mismatch → fail closed.
- SQLite 连接开启 `PRAGMA foreign_keys=ON`
- 手写列同步 `_sync_sqlite_image_columns()` 已退休删除。
The hand-rolled `_sync_sqlite_image_columns()` has been retired and removed.
---
## 11. 部署 / Deployment
**Docker**`Dockerfile`):`python:3.12-slim` → 装依赖 → 拷贝 `app/``uvicorn app.main:app --host 0.0.0.0 --port 10000`
**Compose**`docker-compose.yml`):
- 镜像固定 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`,同时保留 `build:` 用于本地构建。
- `user: "1000:1000"`,仅 `127.0.0.1:${APP_PORT}:10000` 暴露,`${DATA_DIR}:/app/data` 持久化,`restart: unless-stopped`
**一键安装**`scripts/install.sh`,需 root/sudo 写 nginx):读 `.env` → 拷 compose/.env/渲染后的 backup 脚本到 `APP_DIR` → 渲染并启用 nginx 站点 → `nginx -t` + reload → `docker compose pull && up -d` → 写每日 `02:10` 备份 cron。无 `.env` 直接退出。
**轻量更新**`scripts/deploy.sh`):`git pull --ff-only``docker compose pull web``up -d` → 打印状态/日志。
**nginx 模板**`scripts/nginx/...`):80→443 跳转、443 启用 SSL、反代到 `127.0.0.1:${APP_PORT}``client_max_body_size 0`。证书由用户自备于 `SSL_PATH``fullchain.pem` / `privkey.key`)。
**备份**`scripts/backup_db.sh`,模板带占位符由 install 渲染):用 `sqlite3 .backup` 取事务一致快照(不停容器),文件名带时间戳,**最多保留 5 个**,`BACKUP_REMOTE` 非空时 `rclone sync` 到远端。
---
## 12. CI / CD`.github/workflows/`
- **`test.yml`CI**:任意分支 `push` 触发 → Python 3.12 → 装依赖 → `pytest`。无需外部服务/DB。
- **`docker-image.yml`CD**`v*` tag 触发;**先校验该 tag 提交可从 `origin/main` 到达**,再 buildx 构建 `linux/amd64` + `linux/arm64`,推 `:${tag}``:latest`
- 需在仓库 Secrets 配 `REGISTRY_USERNAME` / `REGISTRY_TOKEN`Gitea container registry)。
发布流程 / Release`git tag vX.Y.Z && git push origin main --tags`
---
## 13. 测试 / Tests
- `tests/conftest.py`:每个测试用 `tmp_path` 建独立 SQLite`configure_database(...)` 切换,再 `create_app()` —— **不污染** `data/app.db`,无需 Docker。
Each test gets an isolated tmp SQLite; never touches dev data.
- `tests/test_app.py`(约 74 个):Box/Item/SubItem CRUD、级联删除、404、图片上传/替换/删除/错误路径、EXIF 矫正、图片路由、搜索(name/note/路径/缩略图)、重定向行为、页面结构与 UX 文案、概览统计。
- `tests/test_notion_import.py`9 个):page id 提取、heading/bullet 解析、容器判定、超层级警告、媒体跳过、dry-run 不写库、apply 写库结构。
运行 / Run`python -m pytest`
---
## 14. Notion 一次性导入 / One-time Notion Import
`app/notion_import.py` + `scripts/import_notion.py`(交互式 CLI`--dry-run` / `--apply`)。
结构映射 / Mapping`heading_2``Box`;其下一级 bullet → `Item`;二级 bullet → `SubItem`(此时父 bullet 自动判为容器型)。更深层级只警告不导入;**不导入任何图片/媒体**。
定位 / Positioning**一次性 migration 工具,非长期同步**;建议导入前先备份 `data/app.db``NOTION_VERSION = "2026-03-11"`
---
## 15. 已知约束 & 下一轮改动建议 / Constraints & Notes for the Next Round
**当前明确「未实现」/ Explicitly out of scope(见 README):** 离线缓存/同步、多图、OCR、AI 识别、图片标签/分类、登录鉴权、标签系统、前后端分离、复杂 UI。
**改动前值得注意的点 / Things to watch before changing things**
1. **无鉴权 / No auth.** 设计前提是「可信内网 + nginx HTTPS」。任何要暴露到公网的改动都需先加访问控制。
2. **迁移机制薄弱 / Weak migrations(§10.** 加新字段到已有库不会自动建列。建议:要么扩展 `_sync_sqlite_image_columns` 思路(改成更通用的列同步),要么正式引入 Alembic。
3. **图片存在 SQLite 里 / Images live in the DB.** 好处是备份/迁移只需一个文件;代价是库体积随图增长、备份成本上升。若要支持多图或大图归档,应考虑改为对象存储/文件系统 + 路径引用。
4. **逻辑高度集中在 `main.py` / Logic concentrated in `main.py`.** 路由、表单解析、查询、统计、搜索都在一个文件。新增大功能时可考虑拆分 router/service 模块,但要保留 `create_app()` 工厂以维持测试隔离。
5. **Service Worker 是空壳 / SW is a stub.** README 写的「PWA」目前不含离线能力;要做离线需真正实现缓存策略。
6. **固定 3 级层次 / Fixed 3 levels.** `Box → Item → SubItem` 写死在模型、路由、模板、Notion 解析多处;若要变成可嵌套树,是一次跨层改动。
7. **HEIC 在 Linux 容器里不可用 / HEIC fails in Linux containers**`pillow_heif` 未列入依赖,`sips` 仅 macOS)。若用户多用 iPhone 原图,考虑把 `pillow_heif` 加进 `requirements.txt`
8. **UI 全中文、SSR、单 `style.css`.** 前端改动直接编辑 `app/templates/*``app/static/style.css`,无构建步骤。
---
## 16. 本地快速启动 / Quick Local Start
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 10000
# 打开 / open http://localhost:10000 (默认数据库 / default DB: ./data/app.db)
python -m pytest # 跑测试 / run tests
```