18 KiB
仓库简报 / 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
.
├── 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:
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-checkingis_containeron 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 FKondelete.
图片字段(每个模型都有同一组)/ 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():
- 读取字节,空内容 → 400;非法图片 → 400。Read bytes; empty/invalid → 400.
- 按 EXIF 方向矫正(
ImageOps.exif_transpose),再处理。Apply EXIF orientation first. - 去元数据并转 RGB(
RGBA/LA贴白底、P转 RGB)。Strip metadata, flatten to RGB. - 最长边缩放到 ≤ 1600px(
thumbnail)。Downscale longest side to ≤ 1600px. - 存为 JPEG,质量 80,
optimize=True。Save as JPEG q80. - 写入
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做 SQLiteLOWER(...) LIKE %q%模糊匹配(大小写不敏感)。 Case-insensitiveLIKEovername+noteacross 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()callsverify_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 commandpython -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:
- 无鉴权 / No auth. 设计前提是「可信内网 + nginx HTTPS」。任何要暴露到公网的改动都需先加访问控制。
- 迁移机制薄弱 / Weak migrations(§10). 加新字段到已有库不会自动建列。建议:要么扩展
_sync_sqlite_image_columns思路(改成更通用的列同步),要么正式引入 Alembic。 - 图片存在 SQLite 里 / Images live in the DB. 好处是备份/迁移只需一个文件;代价是库体积随图增长、备份成本上升。若要支持多图或大图归档,应考虑改为对象存储/文件系统 + 路径引用。
- 逻辑高度集中在
main.py/ Logic concentrated inmain.py. 路由、表单解析、查询、统计、搜索都在一个文件。新增大功能时可考虑拆分 router/service 模块,但要保留create_app()工厂以维持测试隔离。 - Service Worker 是空壳 / SW is a stub. README 写的「PWA」目前不含离线能力;要做离线需真正实现缓存策略。
- 固定 3 级层次 / Fixed 3 levels.
Box → Item → SubItem写死在模型、路由、模板、Notion 解析多处;若要变成可嵌套树,是一次跨层改动。 - HEIC 在 Linux 容器里不可用 / HEIC fails in Linux containers(
pillow_heif未列入依赖,sips仅 macOS)。若用户多用 iPhone 原图,考虑把pillow_heif加进requirements.txt。 - UI 全中文、SSR、单
style.css. 前端改动直接编辑app/templates/*与app/static/style.css,无构建步骤。
16. 本地快速启动 / Quick Local Start
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