2026-06-01 13:10:59 +02:00
# 仓库简报 / 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 | Pillow( HEIC 时可选 `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/ # pytest: test_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 显示所属 Box, SubItem 显示所属 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。
- 图标:180( apple-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` (容器内)。
---
2026-06-01 16:02:43 +02:00
## 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.
2026-06-01 13:10:59 +02:00
- SQLite 连接开启 `PRAGMA foreign_keys=ON` 。
2026-06-01 16:02:43 +02:00
- 手写列同步 `_sync_sqlite_image_columns()` 已退休删除。
The hand-rolled `_sync_sqlite_image_columns()` has been retired and removed.
2026-06-01 13:10:59 +02:00
---
## 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
```