diff --git a/.env.example b/.env.example
index 75a54ce..f5843df 100644
--- a/.env.example
+++ b/.env.example
@@ -1,14 +1,32 @@
-# Runtime
-HOST=0.0.0.0
-PORT=10000
+# This file is sourced by shell scripts. Keep values shell-compatible.
-# In Docker, keep the database inside the mounted /app/data directory.
+# Local TLS domain used by nginx and your own certificate files.
+HOST_DOMAIN=moving-helper.lan
+
+# Certificate directory prepared by the user.
+# Place fullchain.pem and privkey.key in this directory.
+# If you use acme.sh, this is typically /etc/acme.sh/$HOST_DOMAIN
+SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
+
+# Deployment target directory used by the install script.
+APP_DIR=$HOME/.local/share/moving-helper
+
+# Backup destination directory used by the deployed backup script.
+BACKUP_DIR=$HOME/.local/backup/moving-helper
+
+# Optional rclone remote target, for example: remote:folder/moving-helper
+BACKUP_REMOTE=
+
+# Host port published by docker compose. The container always listens on 10000.
+APP_PORT=10000
+
+# Database location inside the container.
DATABASE_URL=sqlite:////app/data/app.db
-# Host-side persistent data directory
+# Host-side persistent data directory.
+# Relative paths are resolved from APP_DIR after installation.
DATA_DIR=./data
-# Container user mapping
-UID=1000
-GID=1000
+# Optional compose project name.
+COMPOSE_PROJECT_NAME=moving-helper
diff --git a/.gitignore b/.gitignore
index 95fe68b..04a9445 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
__pycache__/
.pytest_cache/
*.pyc
+.env
data/*.db
# macOS generated files
diff --git a/Dockerfile b/Dockerfile
index fedbe86..9c0cd1c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,8 +3,6 @@ FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
- HOST=0.0.0.0 \
- PORT=10000 \
DATABASE_URL=sqlite:////app/data/app.db
WORKDIR /app
@@ -18,4 +16,4 @@ RUN mkdir -p /app/data
EXPOSE 10000
-CMD ["sh", "-c", "uvicorn app.main:app --host ${HOST:-0.0.0.0} --port ${PORT:-10000}"]
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "10000"]
diff --git a/README.md b/README.md
index c8cd197..9d9e139 100644
--- a/README.md
+++ b/README.md
@@ -138,6 +138,9 @@ Box
├── scripts
│ ├── backup_db.sh
│ └── deploy.sh
+│ ├── install.sh
+│ └── nginx
+│ └── moving-helper.nginx.template
├── tests
├── .dockerignore
├── .env.example
@@ -152,12 +155,15 @@ Box
项目通过环境变量支持以下部署时真正需要关心的配置:
-- `HOST`
-- `PORT`
-- `DATABASE_URL`
+- `HOST_DOMAIN`
+- `SSL_PATH`
+- `APP_DIR`
+- `BACKUP_DIR`
+- `BACKUP_REMOTE`
+- `APP_PORT`
- `DATA_DIR`
-- `UID`
-- `GID`
+- `DATABASE_URL`
+- `COMPOSE_PROJECT_NAME`
推荐从示例文件开始:
@@ -168,20 +174,25 @@ cp .env.example .env
默认值如下:
```env
-HOST=0.0.0.0
-PORT=10000
+HOST_DOMAIN=moving-helper.lan
+SSL_PATH=/etc/acme.sh/$HOST_DOMAIN
+APP_DIR=$HOME/.local/share/moving-helper
+BACKUP_DIR=$HOME/.local/backup/moving-helper
+BACKUP_REMOTE=
+APP_PORT=10000
DATABASE_URL=sqlite:////app/data/app.db
DATA_DIR=./data
-UID=1000
-GID=1000
+COMPOSE_PROJECT_NAME=moving-helper
```
说明:
-- 本地开发默认数据库仍然是 `./data/app.db`
-- Docker 内建议继续使用 `sqlite:////app/data/app.db`
-- `DATA_DIR` 控制宿主机上的持久化目录
-- `UID/GID` 用来让容器内文件权限更贴近宿主机用户
+- 容器内应用固定监听 `0.0.0.0:10000`
+- `APP_PORT` 只控制宿主机暴露端口,nginx 默认反代到这个端口
+- `APP_DIR` 是安装脚本复制 compose、`.env`、备份脚本等运行资产的目标目录
+- `DATA_DIR` 默认为相对路径 `./data`,安装后会相对于 `APP_DIR` 解析
+- `SSL_PATH` 由用户自行准备证书目录,安装脚本不会签发证书
+- `.env` 会被 shell 脚本直接 `source`,请保持 shell 兼容写法
## 本地开发模式
@@ -227,6 +238,13 @@ Docker / Compose 是这个项目面向长期运行环境的方式。
- `image`:固定指向 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
- `build`:用于本地开发时从当前代码构建镜像
+当前部署约定已经收敛为:
+
+- 容器内应用固定监听 `0.0.0.0:10000`
+- compose 固定使用 `user: 1000:1000`
+- 宿主机仅在 `127.0.0.1:${APP_PORT}` 暴露后端端口
+- SQLite 固定写入容器内 `/app/data/app.db`
+
### 首次准备
```bash
@@ -271,10 +289,10 @@ http://localhost:10000
当前 `docker-compose.yml` 保持尽量简单:
-- 默认镜像地址来自 `REGISTRY_HOST / IMAGE_NAME / IMAGE_TAG`
-- 默认暴露 `10000` 端口
+- 固定镜像地址为 `code.wanderingbadger.dev/tliu93/2026-moving-helper:latest`
+- 宿主机默认仅在 `127.0.0.1:10000` 暴露容器 `10000`
- `restart: unless-stopped`
-- 容器用户来自 `UID:GID`
+- 容器固定使用 `1000:1000`
- 宿主机 `DATA_DIR` 挂载到容器内 `/app/data`
- SQLite 默认写入 `/app/data/app.db`
@@ -285,7 +303,33 @@ http://localhost:10000
## 自动化部署
-这个项目没有复杂 CI/CD,只提供一个适合家用项目的轻量部署脚本:
+这个项目现在额外提供一个面向本地网络环境的最小安装脚本:
+
+```bash
+sh scripts/install.sh
+```
+
+安装脚本会执行:
+
+1. 检查项目根目录下是否存在 `.env`
+2. 读取 `.env`
+3. 把 `docker-compose.yml`、`.env` 和渲染后的 `backup_db.sh` 复制到 `APP_DIR`
+4. 用 `HOST_DOMAIN`、`SSL_PATH`、`APP_PORT` 渲染 nginx 配置
+5. 写入 `/etc/nginx/sites-available/moving-helper-nginx`
+6. 创建到 `/etc/nginx/sites-enabled/` 的符号链接
+7. 执行 `nginx -t` 并 reload nginx
+8. 在 `APP_DIR` 下执行 `docker compose pull` 和 `docker compose up -d`
+9. 为当前用户写入每日 `02:10` 的 backup cron
+
+其中以下步骤需要 root 或 sudo:
+
+- 写入 nginx 配置
+- 执行 `nginx -t`
+- reload nginx
+
+如果 `.env` 不存在,脚本会直接退出,不会继续做任何安装动作。
+
+如果你只想在仓库目录里做一次手动更新,也保留了一个轻量部署脚本:
```bash
./scripts/deploy.sh
@@ -296,7 +340,8 @@ http://localhost:10000
1. 检查 `.env`
2. 准备数据目录
3. 如果当前目录是 git 仓库,执行 `git pull --ff-only`
-4. 执行 `docker compose up -d --build`
+4. 执行 `docker compose pull web`
+5. 执行 `docker compose up -d`
5. 输出容器状态
6. 输出最近日志
@@ -305,7 +350,8 @@ http://localhost:10000
如果你不想自动拉代码,也可以直接手动运行:
```bash
-docker compose up -d --build
+docker compose pull
+docker compose up -d
```
## 数据持久化
@@ -335,32 +381,83 @@ ${DATA_DIR:-./data}
## 备份与恢复
-### 最简单的备份方式
+### 备份机制
-直接复制 SQLite 文件即可:
+安装脚本会把渲染后的备份脚本安装到:
+
+- `APP_DIR/backup_db.sh`
+
+并为当前用户创建一条 cron:
+
+- `10 2 * * *`
+
+备份行为如下:
+
+- 目标目录是 `BACKUP_DIR`
+- 备份文件名带时间戳,例如 `app-20260421-021000.db`
+- 最多保留 5 个本地备份
+- 如果 `BACKUP_REMOTE` 非空,会在本地备份完成后调用 `rclone copyto`
+
+SQLite 一致性策略:
+
+- 备份脚本优先使用 `sqlite3` 的 `.backup`
+- 不停容器
+- 不直接 `cp` 正在写入的数据库文件
+
+这样可以在应用仍然运行时生成事务一致的快照,避免简单文件复制带来的损坏风险。
+
+### 手动执行备份
+
+如果你想手动触发一次备份:
```bash
-cp data/app.db backups/app.db
+sh "$APP_DIR/backup_db.sh"
```
-或者使用附带脚本:
+如果当前还没有执行安装脚本,也可以在仓库内手动准备 `.env` 后运行:
```bash
./scripts/backup_db.sh
```
-它会在 `backups/` 目录下生成一个带时间戳的副本。
+前提是先通过安装脚本把它渲染并部署到 `APP_DIR`,因为仓库内版本本身是带占位符的模板。
-### 最简单的恢复方式
+### 恢复大致步骤
停止容器后,把备份文件覆盖回去:
```bash
+cd "$APP_DIR"
docker compose stop
-cp backups/app-YYYYMMDD-HHMMSS.db data/app.db
+cp "$BACKUP_DIR/app-YYYYMMDD-HHMMSS.db" "${DATA_DIR:-./data}/app.db"
docker compose up -d
```
+如果 `DATA_DIR` 是相对路径,记得在 `APP_DIR` 下执行这些命令。
+
+### nginx 与证书约定
+
+仓库提供的 nginx 模板位于:
+
+- `scripts/nginx/moving-helper.nginx.template`
+
+安装脚本会把它渲染成 Debian / Ubuntu 风格的站点配置:
+
+- `/etc/nginx/sites-available/moving-helper-nginx`
+- `/etc/nginx/sites-enabled/moving-helper-nginx`
+
+模板约定:
+
+- 80 端口强制跳转到 443
+- 443 默认启用 SSL
+- 反代到仅绑定在本机回环地址上的 `127.0.0.1:${APP_PORT}`
+- `client_max_body_size 0`
+
+证书文件需要由用户自己准备在 `SSL_PATH` 下,当前模板默认引用:
+
+- `fullchain.pem`
+- `privkey.key`
+
## 常见排查
### 1. 查看容器日志
diff --git a/app/templates/base.html b/app/templates/base.html
index 6d6b31d..0d18852 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -4,7 +4,7 @@
{{ page_title or "搬家助手" }}
-
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 81cc0a5..71c35e4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,12 +4,10 @@ services:
image: "code.wanderingbadger.dev/tliu93/2026-moving-helper:latest"
build:
context: .
- user: "${UID:-1000}:${GID:-1000}"
+ user: "1000:1000"
ports:
- - "${PORT:-10000}:${PORT:-10000}"
+ - "127.0.0.1:${APP_PORT:-10000}:10000"
environment:
- HOST: ${HOST:-0.0.0.0}
- PORT: ${PORT:-10000}
DATABASE_URL: ${DATABASE_URL:-sqlite:////app/data/app.db}
volumes:
- ${DATA_DIR:-./data}:/app/data
diff --git a/scripts/backup_db.sh b/scripts/backup_db.sh
index 63087cd..02e3036 100755
--- a/scripts/backup_db.sh
+++ b/scripts/backup_db.sh
@@ -1,27 +1,83 @@
#!/usr/bin/env sh
set -eu
-PROJECT_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
-cd "$PROJECT_ROOT"
+APP_DIR="__APP_DIR__"
+DEFAULT_BACKUP_DIR="__BACKUP_DIR__"
+ENV_FILE="$APP_DIR/.env"
-if [ ! -f ".env" ] && [ -f ".env.example" ]; then
- echo "未找到 .env,先从 .env.example 复制一份:"
- echo " cp .env.example .env"
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required command: $1" >&2
+ exit 1
+ fi
+}
+
+resolve_path() {
+ case "$1" in
+ /*) printf '%s\n' "$1" ;;
+ *) printf '%s/%s\n' "$APP_DIR" "$1" ;;
+ esac
+}
+
+if [ ! -f "$ENV_FILE" ]; then
+ echo "Deployed .env file not found: $ENV_FILE" >&2
exit 1
fi
-DATA_DIR_VALUE=$(grep '^DATA_DIR=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || true)
-DATA_DIR=${DATA_DIR_VALUE:-./data}
-DB_PATH="$DATA_DIR/app.db"
+set -a
+. "$ENV_FILE"
+set +a
+
+require_command sqlite3
+
+if [ -n "${BACKUP_REMOTE:-}" ]; then
+ require_command rclone
+fi
+
+BACKUP_DIR=${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}
+DATA_DIR=${DATA_DIR:-./data}
+DB_PATH="$(resolve_path "$DATA_DIR")/app.db"
if [ ! -f "$DB_PATH" ]; then
- echo "未找到数据库文件:$DB_PATH"
+ echo "Database file not found: $DB_PATH" >&2
exit 1
fi
-mkdir -p backups
+mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
-DESTINATION="backups/app-$TIMESTAMP.db"
+TMP_BACKUP="$BACKUP_DIR/.app-$TIMESTAMP.db.tmp"
+FINAL_BACKUP="$BACKUP_DIR/app-$TIMESTAMP.db"
-cp "$DB_PATH" "$DESTINATION"
-echo "备份已创建:$DESTINATION"
+cleanup() {
+ rm -f "$TMP_BACKUP"
+}
+
+trap cleanup EXIT INT TERM
+
+# Prefer sqlite3 .backup so the snapshot stays transactionally consistent without
+# stopping the running container or racing with SQLite writes.
+sqlite3 "$DB_PATH" </dev/null | tail -n 1 | cut -d '=' -f 2- || true)
-DATA_DIR=${DATA_DIR_VALUE:-./data}
+set -a
+. ./.env
+set +a
+
+DATA_DIR=${DATA_DIR:-./data}
+APP_PORT=${APP_PORT:-10000}
mkdir -p "$DATA_DIR"
-echo "[1/4] 拉取最新代码(如果当前目录是 git 仓库)"
+echo "[1/4] Pull latest code if this directory is a git repository"
if [ -d ".git" ]; then
git pull --ff-only
else
- echo "跳过:当前目录不是 git 仓库"
+ echo "Skipped: current directory is not a git repository"
fi
-echo "[2/4] 构建并更新容器"
-docker compose up -d --build
+echo "[2/4] Pull and update containers"
+docker compose pull web
+docker compose up -d
-echo "[3/4] 当前容器状态"
+echo "[3/4] Current container status"
docker compose ps
-echo "[4/4] 最近日志"
+echo "[4/4] Recent logs"
docker compose logs --tail=50 web
echo
-echo "部署完成。应用默认地址:"
-echo " http://localhost:$(grep '^PORT=' .env 2>/dev/null | tail -n 1 | cut -d '=' -f 2- || echo 10000)"
+echo "Deployment complete. Default application URLs:"
+echo " https://${HOST_DOMAIN:-localhost}"
+echo " Backend port mapping: localhost:$APP_PORT -> container:10000"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..1fb3305
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env sh
+set -eu
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
+SOURCE_ENV="$PROJECT_ROOT/.env"
+COMPOSE_SOURCE="$PROJECT_ROOT/docker-compose.yml"
+BACKUP_TEMPLATE="$SCRIPT_DIR/backup_db.sh"
+NGINX_TEMPLATE="$SCRIPT_DIR/nginx/moving-helper.nginx.template"
+NGINX_SITE_NAME="moving-helper-nginx"
+CRON_MARKER="# moving-helper-backup"
+
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required command: $1" >&2
+ exit 1
+ fi
+}
+
+run_as_root() {
+ if [ "$(id -u)" -eq 0 ]; then
+ "$@"
+ elif command -v sudo >/dev/null 2>&1; then
+ sudo "$@"
+ else
+ echo "This step requires root privileges, but sudo is not available: $*" >&2
+ exit 1
+ fi
+}
+
+escape_sed_replacement() {
+ printf '%s' "$1" | sed 's/[|&]/\\&/g'
+}
+
+render_template() {
+ src=$1
+ dst=$2
+ host_domain_escaped=$(escape_sed_replacement "$HOST_DOMAIN")
+ ssl_path_escaped=$(escape_sed_replacement "$SSL_PATH")
+ app_port_escaped=$(escape_sed_replacement "$APP_PORT")
+ app_dir_escaped=$(escape_sed_replacement "$APP_DIR")
+ backup_dir_escaped=$(escape_sed_replacement "$BACKUP_DIR")
+
+ sed \
+ -e "s|__HOST_DOMAIN__|$host_domain_escaped|g" \
+ -e "s|__SSL_PATH__|$ssl_path_escaped|g" \
+ -e "s|__APP_PORT__|$app_port_escaped|g" \
+ -e "s|__APP_DIR__|$app_dir_escaped|g" \
+ -e "s|__BACKUP_DIR__|$backup_dir_escaped|g" \
+ "$src" > "$dst"
+}
+
+if [ ! -f "$SOURCE_ENV" ]; then
+ echo "Missing $SOURCE_ENV" >&2
+ echo "Create it first: cp .env.example .env" >&2
+ exit 1
+fi
+
+require_command docker
+require_command crontab
+
+if ! docker compose version >/dev/null 2>&1; then
+ echo "The docker compose plugin is not available in the current environment" >&2
+ exit 1
+fi
+
+set -a
+. "$SOURCE_ENV"
+set +a
+
+HOST_DOMAIN=${HOST_DOMAIN:-}
+SSL_PATH=${SSL_PATH:-}
+APP_DIR=${APP_DIR:-$HOME/.local/share/moving-helper}
+BACKUP_DIR=${BACKUP_DIR:-$HOME/.local/backup/moving-helper}
+APP_PORT=${APP_PORT:-10000}
+DATA_DIR=${DATA_DIR:-./data}
+
+if [ -z "$HOST_DOMAIN" ]; then
+ echo "HOST_DOMAIN is not configured" >&2
+ exit 1
+fi
+
+if [ -z "$SSL_PATH" ]; then
+ echo "SSL_PATH is not configured" >&2
+ exit 1
+fi
+
+mkdir -p "$APP_DIR" "$BACKUP_DIR" "$APP_DIR/logs"
+
+case "$DATA_DIR" in
+ /*) mkdir -p "$DATA_DIR" ;;
+ *) mkdir -p "$APP_DIR/$DATA_DIR" ;;
+esac
+
+cp "$COMPOSE_SOURCE" "$APP_DIR/docker-compose.yml"
+cp "$SOURCE_ENV" "$APP_DIR/.env"
+
+rendered_backup=$(mktemp)
+rendered_nginx=$(mktemp)
+trap 'rm -f "$rendered_backup" "$rendered_nginx"' EXIT INT TERM
+
+render_template "$BACKUP_TEMPLATE" "$rendered_backup"
+install -m 0755 "$rendered_backup" "$APP_DIR/backup_db.sh"
+
+render_template "$NGINX_TEMPLATE" "$rendered_nginx"
+run_as_root install -d /etc/nginx/sites-available /etc/nginx/sites-enabled
+run_as_root install -m 0644 "$rendered_nginx" "/etc/nginx/sites-available/$NGINX_SITE_NAME"
+run_as_root ln -sfn "/etc/nginx/sites-available/$NGINX_SITE_NAME" "/etc/nginx/sites-enabled/$NGINX_SITE_NAME"
+
+run_as_root nginx -t
+if command -v systemctl >/dev/null 2>&1; then
+ run_as_root systemctl reload nginx
+else
+ run_as_root service nginx reload
+fi
+
+(
+ cd "$APP_DIR"
+ docker compose pull web
+ docker compose up -d
+)
+
+cron_tmp=$(mktemp)
+existing_cron=$(mktemp)
+trap 'rm -f "$rendered_backup" "$rendered_nginx" "$cron_tmp" "$existing_cron"' EXIT INT TERM
+
+crontab -l 2>/dev/null > "$existing_cron" || true
+grep -v "moving-helper-backup" "$existing_cron" > "$cron_tmp" || true
+printf '10 2 * * * %s/backup_db.sh >> %s/logs/backup.log 2>&1 %s\n' "$APP_DIR" "$APP_DIR" "$CRON_MARKER" >> "$cron_tmp"
+crontab "$cron_tmp"
+
+echo "Installation complete."
+echo "- Application directory: $APP_DIR"
+echo "- Backup directory: $BACKUP_DIR"
+echo "- Nginx config: /etc/nginx/sites-available/$NGINX_SITE_NAME"
+echo "- URL: https://$HOST_DOMAIN"
+echo "- Scheduled backup: daily at 02:10 via the current user's crontab"
\ No newline at end of file
diff --git a/scripts/nginx/moving-helper.nginx.template b/scripts/nginx/moving-helper.nginx.template
new file mode 100644
index 0000000..bf02161
--- /dev/null
+++ b/scripts/nginx/moving-helper.nginx.template
@@ -0,0 +1,29 @@
+server {
+ listen 80;
+ server_name __HOST_DOMAIN__;
+
+ return 301 https://$host$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ http2 on;
+ server_name __HOST_DOMAIN__;
+
+ ssl_certificate __SSL_PATH__/fullchain.pem;
+ ssl_certificate_key __SSL_PATH__/privkey.key;
+
+ client_max_body_size 0;
+
+ location / {
+ proxy_pass http://127.0.0.1:__APP_PORT__;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-Host $host;
+ proxy_set_header X-Forwarded-Port 443;
+ proxy_read_timeout 300;
+ }
+}
\ No newline at end of file
diff --git a/tests/test_app.py b/tests/test_app.py
index d05ab58..d784180 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -103,6 +103,14 @@ def test_boxes_page_returns_200(client):
assert "箱子" in response.text
+def test_boxes_page_uses_relative_stylesheet_path(client):
+ response = client.get("/boxes")
+
+ assert response.status_code == 200
+ assert 'href="/static/style.css"' in response.text
+ assert "http://" not in response.text.split("/static/style.css")[0]
+
+
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
box = Box(
name="Kitchen Box",