301 lines
18 KiB
Markdown
301 lines
18 KiB
Markdown
# 仓库简报 / 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
|
||
```
|