# 仓库简报 / 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`(容器内)。 --- ## 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 ```