文件
workbuddy-portal/docs/DEPLOYMENT.md
T
wangchuanli 4720149c20 fix(发布): push-all.sh 补上 git tag,并补打 v1.2.0 ~ v1.4.0 历史标签
问题:脚本里只有 docker tag / docker push,**没有 git tag**,所以远程仓库
一直只有软件包、没有版本 tag —— 「某个版本对应哪个提交」只能靠翻 CHANGELOG 猜。
镜像 tag 只说明「注册表里有这个包」,回答不了「这个包是哪份代码构建的」。

tools/push-all.sh:
- 新增**版本预检**(传了版本号时):从 workbuddy_portal/__init__.py 读
  __version__ 与传入值比对,不一致立刻退出 —— 放在最前面,免得代码和镜像
  都推完了才发现「代码写着 1.4.0、却当 1.5.0 发」
- 新增 git tag -a vX.Y.Z + 推送该 tag,**放在代码与镜像都推成功之后**:
  tag 一旦出现在远端就是「这个版本发过了」的公开声明,不该先于产物出现
- 已存在的 tag 只提示、不改写(指向哪个提交由历史决定)

补打历史标签:v1.2.0 / v1.3.0 / v1.4.0,各指向引入该版本号的那个提交
(1.1.0 在仓库里没有对应提交,故未补)。

文档:
- DEPLOYMENT 7.1 补上「为什么必须有 git tag」与版本预检说明
- DEPLOYMENT 7.4 补上「镜像与 tag 要分别核验」的命令(git ls-remote --tags)
  —— 此前 7.4 只验镜像,正是这个盲区让「没有 tag」一直没被发现

验证:python tools/check_docs.py → 0 处问题;python tools/smoke.py → ok=264 fail=0
2026-09-18 14:54:48 +08:00

1433 行
70 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 部署与运维指南
> 面向**部署者 / 运维**。三条并列的部署路径,选一条走到底;之后是备份、升级、排错。
>
> 想了解界面怎么用,看 [用户使用指南](USER-GUIDE.md);想了解内部结构,看 [架构说明](ARCHITECTURE.md)。
**目录**
- [一、三条部署路径怎么选](#一三条部署路径怎么选)
- [二、部署前准备(三条路径共用)](#二部署前准备三条路径共用)
- [三、路径 A:裸机部署](#三路径-a裸机部署)
- [四、路径 B:Docker 自打包部署](#四路径-bdocker-自打包部署)
- [五、路径 C:docker-compose 拉云端镜像部署](#五路径-cdocker-compose-拉云端镜像部署)
- [六、反向代理与 HTTPS](#六反向代理与-https)
- [七、把代码与镜像推到 Gitea 注册表](#七把代码与镜像推到-gitea-注册表)
- [八、备份与恢复](#八备份与恢复)
- [九、升级与回滚](#九升级与回滚)
- [十、日常巡检](#十日常巡检)
- [十一、排错](#十一排错)
- [十二、配置项速查](#十二配置项速查)
- [十三、安全与隐私基线](#十三安全与隐私基线)
---
## 一、三条部署路径怎么选
| | 路径 A:裸机 | 路径 B:Docker 自打包 | 路径 C:compose 拉云端镜像 |
|---|---|---|---|
| **宿主机要装什么** | Python 3.11+ | Docker + Compose v2 | Docker + Compose v2 |
| **需要仓库源码吗** | 需要 | 需要 | **不需要**(只要一个 compose 文件) |
| **首次部署耗时** | 约 2 分钟 | 构建约 2~4 分钟 | 拉镜像约 30 秒 |
| **升级方式** | `git pull` + 重启服务 | `git pull` + `up -d --build` | 改 tag + `pull` + `up -d` |
| **适合** | Windows 工作站、没有 Docker 的机器、需要调试代码 | 自己维护镜像、要固定版本 | NAS / 服务器 / 只想跑起来 |
| **持久化位置** | 仓库下的 `data/`、`logs/` | 命名卷 `workbuddy-portal_wb_data` | 命名卷 `workbuddy-portal_wb_data` |
**三者用的是同一份代码、同一个数据库格式**,所以可以在它们之间来回迁移 —— 数据正本只有
一个文件 `usage.sqlite`(外加必须一起搬的 `instance.json`,见 [第八节](#八备份与恢复))。
> ### ⚠️ 不要横向扩展
> SQLite 是**单写者**,调度线程也跑在 Web 进程内。多副本不会更快,只会带来
> `database is locked` 竞争与**重复采集**。这个服务天然是单实例的。
> 真要跑多份,除第一份之外全部设 `WB_DISABLE_SCHEDULER=1`。
---
## 二、部署前准备(三条路径共用)
### 2.1 硬件与系统要求
| 项 | 要求 |
|---|---|
| CPU / 内存 | 任意 x86-64;内存在 256 MB 以上即可 |
| 磁盘 | ≥ 500 MB(镜像约 152 MB + 数据;数据库按每日约 1.5 MB 增长) |
| 系统 | Linux(x86-64 / arm64)、Windows 10+、macOS |
| 时区 | **必须是东八区**,否则「今日」口径与调度时刻都会错位(见 [11.6](#116-时区不对导致日期错位)) |
### 2.2 仓库目录结构
```
workbuddy-portal/
├── manage.py # 唯一入口(init / serve / collect / users / stats …)
├── requirements.txt
├── Dockerfile # 多阶段构建
├── docker-compose.yml # 路径 B
├── docker-compose.hostdir.yml # 可选叠加层:数据放宿主机目录(只建议 Linux)
├── .env.example # 复制成 .env 再改
├── docker/
│ ├── entrypoint.sh # 容器入口:init → 可选导入 → serve
│ └── healthcheck.py # 只用标准库的健康检查
├── workbuddy_portal/ # 应用代码
│ ├── config.py # 启动期常量 + 配置作用域(GLOBAL_KEYS / USER_EDITABLE_KEYS)
│ ├── db.py # 建表 / 迁移 / 设置读写
│ ├── crypto.py # Cookie 静态加密(零依赖 ChaCha20 + HMAC)
│ ├── captcha.py # 图形验证码(零依赖手写 PNG)
│ ├── collect.py # 采集(单写者锁)
│ ├── query.py # 聚合查询
│ ├── scheduler.py # 进程内调度线程
│ └── web/ # 蓝图 + 模板 + 静态资源
├── data/ # 【运行时数据】正本 + exports/ + instance.json
├── logs/ # 【运行时数据】app.log(滚动 2 MB × 3)
├── backups/ # 数据库快照(人工/脚本产物,不入库)
├── docs/ # 本文档所在处,images/ 是手册配图
└── tools/ # smoke.py / check_live.py / demo_data.py / shots.py / push-all.sh
```
同级的工作区根下还有一个 `legacy-v1/`(v1.0 单文件版归档,仅作迁移来源,可删),
详见它自带的 README。
### 2.3 先决定两件事
**① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。
```bash
python manage.py init --user admin --password '你的强密码'
```
> **不设密码会怎样**(v1.4.0 起):程序用 `secrets.token_urlsafe(12)` 生成一个**随机**口令,
> 用醒目的方框打印在输出里,**只在这一次打印**。库里只有散列,日志一滚就再也拿不回来。
> **没有 `admin123` 这类默认口令了** —— 硬编码一个默认口令等于把公网实例的钥匙挂在门上。
> 所以要么现在就显式设 `WB_ADMIN_PASSWORD`,要么把打印出来的那行抄走。
> 容器里的对应做法见 [11.10 拿不到管理员初始口令](#1110-拿不到管理员初始口令)。
**② 访问方式。** 决定 `WB_BIND` 与 `WB_COOKIE_SECURE`:
| 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | `WB_TRUST_PROXY` |
|---|---|---|---|
| 只本机用 | `127.0.0.1` | `0` | `0` |
| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | **`0`** |
| 域名 + HTTPS 反代 | `127.0.0.1` | `1` | **`1`**(前提:反代会写 XFF,见第六节) |
> `WB_TRUST_PROXY` 是 v1.4.0 新增的,**默认 0**。直接暴露给公网时留 0;
> 设成 1 而反代又没写该头,攻击者每次换一个伪造的 `X-Forwarded-For` 就能让
> 验证码限速 / 注册配额 / 登录锁定**三道防线同时失效**(实测 45 次请求全部放行)。
### 2.4 从 v1.0 迁移历史数据(可选)
如果你有旧版单文件脚本攒下的 CSV(`usage_records.csv`),新版本能直接导入:
```bash
python manage.py migrate-csv # 自动按候选路径查找
python manage.py migrate-csv /path/to/old.csv # 或显式指定
python manage.py migrate-csv -u alice # 指定这份存档算谁的
```
查找顺序定义在 `config.LEGACY_CSV_CANDIDATES`,工作区级 `legacy-v1/data/usage_records.csv`
是**第一候选**。导入是**只读**的 —— 不改动、不删除原 CSV,可以重复执行(主键去重)。
---
## 三、路径 A:裸机部署
适合:Windows 工作站、没有 Docker 的机器、需要直接调试代码的场景。
### 3.1 Linux
```bash
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
# 首个管理员(只在数据库为空时生效)
.venv/bin/python manage.py init --user admin --password '你的强密码'
# 先前台跑一次,确认能起
.venv/bin/python manage.py serve
# 浏览器打开 http://<IP>:8848
```
跑通后交给 systemd(先 `Ctrl+C` 停掉前台进程):
`/etc/systemd/system/workbuddy-portal.service`
```ini
[Unit]
Description=WorkBuddy Portal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=workbuddy
Group=workbuddy
WorkingDirectory=/opt/workbuddy-portal
# TZ 直接决定调度时刻与所有日期口径,务必设对
Environment=TZ=Asia/Shanghai
Environment=WB_COOKIE_SECURE=0
# 备份落点默认就是 <仓库>/backups,显式写出来便于以后搬迁
Environment=WB_BACKUP_DIR=/opt/workbuddy-portal/backups
# 直连(前面没有反代)时保持 0 —— 设成 1 会让三道 IP 防线可被伪造 XFF 绕过
Environment=WB_TRUST_PROXY=0
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
Restart=always
RestartSec=5
# ---- 资源上限(与容器部署的 cpus / mem_limit 对应)----
# 单写者架构下不靠加副本扛负载,所以「限制单实例吃多少」才是正解。
# 数值按需调整:内存超限被 OOM 杀掉有明确日志,比「越来越慢」好查得多。
CPUQuota=100%
MemoryMax=512M
MemorySwapMax=0
TasksMax=64
# ---- 加固(可选但建议)----
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs /opt/workbuddy-portal/backups
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now workbuddy-portal
sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f
```
> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/`、`backups/` 归它所有:
> ```bash
> sudo useradd --system --home /opt/workbuddy-portal --shell /usr/sbin/nologin workbuddy
> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data \
> /opt/workbuddy-portal/logs \
> /opt/workbuddy-portal/backups
> ```
> `backups/` 千万别漏 —— 漏了的表现是「备份管理」页能打开,但一备份就报错,
> 日志里是 `PermissionError`(服务以 `workbuddy` 身份跑,写不进 root 拥有的目录)。
### 3.2 Windows
```bat
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
py -3 -m venv .venv
.venv\Scripts\pip install --upgrade pip
.venv\Scripts\pip install -r requirements.txt
.venv\Scripts\python manage.py init --user admin --password 你的强密码
.venv\Scripts\python manage.py serve
```
开机自启用「任务计划程序」:
| 项 | 值 |
|---|---|
| 触发器 | 计算机启动时 |
| 操作 → 程序 | `<项目路径>\.venv\Scripts\python.exe` |
| 操作 → 参数 | `manage.py serve --host 0.0.0.0 --port 8848` |
| 操作 → 起始位置 | `<项目路径>` |
| 设置 | 勾「如果任务失败,按以下频率重新启动」,间隔 1 分钟,尝试 3 次 |
> Windows 上 `--host 0.0.0.0` 首次启动会弹防火墙授权,选「专用网络」即可。
> 系统时区务必是 `(UTC+08:00) 北京`,否则日期口径会错一天。
### 3.3 裸机部署的调度问题
调度线程在 **Web 进程内**,所以:
- 停掉服务 = 停掉调度;
- **不建议**用 cron / 计划任务去跑 `manage.py collect` 代替内置调度。
真要走外部调度,就给 Web 端加 `--no-scheduler`,避免两边抢锁(文件锁能保证正确性,
但会白跑一次)。
> 关机期间错过的时刻,靠「启动补跑」找回:下次启动时,已错过、且还在
> `catch_up_grace_hours`(默认 12 小时)宽限期内的槽位会自动补采一次。
---
## 四、路径 B:Docker 自打包部署
适合:自己维护镜像、要固定版本、要推送到自己的注册表。**从源码构建镜像。**
### 4.1 前置
- Docker Engine 20.10+(含 Compose v2)
- 磁盘:镜像 152 MB + 构建缓存
### 4.2 步骤
```bash
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
cp .env.example .env
vi .env # 至少设置 WB_ADMIN_PASSWORD
docker compose up -d --build
docker compose ps # 等 STATUS 变成 (healthy)
docker compose logs -f
```
浏览器打开 `http://<服务器IP>:8848`。
### 4.3 构建方式
`Dockerfile` 是**多阶段构建**,不是随手写的:
| 阶段 | 做什么 | 为什么 |
|---|---|---|
| `builder` | 只 `pip install -r requirements.txt` 到 `/opt/venv` | 依赖层与源码层解耦:**改业务代码不会触发重装依赖**,重建通常十几秒 |
| `runtime` | `python:3.13-slim` + 拷 venv + 非 root 用户 `app(uid 1000)` | 镜像里不留 pip 缓存与编译工具;进程不以 root 跑 |
另外:装 `tzdata` 并 `ln -snf` 到 `/etc/localtime`(否则容器内 `TZ` 不生效,
调度时刻会错),`PYTHONDONTWRITEBYTECODE=1`(不在卷里留 `__pycache__`),
`init: true`(tini 接管 PID 1,`docker stop` 能干净传到 python)。
### 4.4 `.env` 主要变量
#### 监听与调度
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` |
| `WB_PORT` | `8848` | 宿主机端口 |
| `TZ` | `Asia/Shanghai` | **影响调度时刻与所有日期口径** |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集)。多副本时除第一个外都要设 1 |
| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) |
| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空则随机生成并只在启动日志打印一次**(v1.4.0 起不再有 `admin123` 兜底) |
| `WB_THREADS` | `4`(compose)/ `8`(代码) | waitress 线程数 = 单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复) |
| `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) |
#### 容器资源上限(v1.4.0 起)
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_CPUS` | `1.0` | CPU 上限(允许小数)。SQLite 是单写者,1 核够用,压测后再调 |
| `WB_MEM_LIMIT` | `512m` | 内存上限。**同时会设 `memswap_limit` 为同值 = 禁用 swap** |
| `WB_PIDS_LIMIT` | `256` | 进程/线程数上限,挡 fork 炸弹 |
> **为什么 swap 要禁用**:不禁用的话内存超限会悄悄滑进 swap,表现为「越来越慢」
> 而不是「被 OOM 杀掉」——后者有明确的日志和退出码,前者只会让人怀疑磁盘。
> `nofile` 上限(4096:8192)在 compose 里写死,因为 SQLite 除主库外还持有 `-wal` `-shm`,
> 恢复期间还要开临时库 + `ATTACH` 源库,默认 1024 在高并发下偏紧。
#### 反向代理与传输安全(**三个必须一起决定**)
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_TRUST_PROXY` | `0` | 是否信任 `X-Forwarded-For`。**默认不信任**。设 1 的前提是「你自己的反代会写这个头」(见第六节,两种 nginx 写法都安全) |
| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转到 https(POST 不跳,否则会掉请求体) |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** |
| `WB_ACCESS_LOG` | `1` | 是否记访问日志(写进 `logs/app.log`,跳过静态资源与验证码图) |
> **HSTS 的触发条件正是 `WB_COOKIE_SECURE` 或 `WB_FORCE_HTTPS` 为 1** ——
> 所以纯 HTTP 部署不会下发 HSTS(下发会让浏览器强升 https,表现成白屏)。
#### 可选:启动时自动导入
| 变量 | 默认 | 说明 |
|---|---|---|
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx |
| `WB_BACKUP_DIR` | `/app/backups` | 归档落点(相对**仓库目录**,不是 `data/`,理由见 [8.1](#81-备份什么)) |
> `TZ`、`WB_COOKIE_SECURE`、`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、
> `WB_THREADS`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` 都是**进程环境变量**,
> `docker compose restart` **不生效**,必须 `docker compose up -d` 重建容器。
### 4.5 数据落点:命名卷,不是绑定挂载
| 容器内 | 存哪 | 内容 |
|---|---|---|
| `/app/data` | 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(密钥)、`exports/` |
| `/app/logs` | 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) |
| `/app/backups` | 命名卷 `workbuddy-portal_wb_backups` | 备份归档 `usage-<时间戳>.zip`(**含 `instance.json`,机密等级等同密钥**) |
> **备份为什么必须单独一个卷**:如果和 `wb_data` 同卷,一次 `docker compose down -v`
> 或卷损坏会把正本与备份**一起**带走 —— 那正好是最需要备份的时刻。
> 而且容器里的 `/app/backups` 若不挂卷,写进去的文件只存在于容器**可写层**:
> `docker restart` 还能留住,但 `docker compose up -d --build` 重建容器就没了 ——
> 「构建完发现备份全丢」是个会真的踩到的坑。现在挂的是独立命名卷。
**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的):
> Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。
> 宿主的 Windows 进程只要访问过这个 WAL 库 —— **哪怕只是 `manage.py stats` 这种纯读** ——
> 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`,
> 而且**不会自愈**,必须重启容器。复现步骤见 [11.5](#115-windows-绑定挂载的坑容器打不开数据库)。
命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题,
所以**容器独占数据目录是唯一在所有平台上都正确的做法**。
**代价**:宿主机的 `manage.py` 不能直接读容器里的库。替代做法都是一行命令:
```bash
docker compose exec portal python manage.py stats
docker compose exec portal python manage.py vacuum
docker compose exec portal python manage.py passwd admin 新密码
docker compose logs -f portal
```
#### 想让宿主机直接看到数据 / 日志 / 备份
叠加 `docker-compose.hostdir.yml`:
```bash
docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d
# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR / WB_HOST_BACKUP_DIR 指定目标目录
docker volume rm workbuddy-portal_wb_backups # 叠加后这个卷没人用,可删
```
> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器同一文件系统,WAL 语义正常)。
> Windows + Docker Desktop 下必然踩上面那个坑。
>
> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000
> 不一致:`sudo chown -R 1000:1000 ./data ./logs ./backups`
### 4.6 常用命令
```bash
docker compose ps
docker compose logs -f --tail=100
docker compose logs portal | grep -A6 '管理员初始口令' # 首次初始化时唯一一次机会
docker compose restart # 注意:环境变量改动 restart 不生效
docker compose down # 停并删容器,数据保留在卷里
docker compose down -v # ⚠️ 连卷一起删(数据 + 日志 + 备份全没)
docker compose up -d --build # 改完代码重新构建
docker compose exec portal python manage.py collect # 手动采集一次
docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态
docker compose exec portal python manage.py status # 调度与最近采集
docker compose exec portal python manage.py backups # 现有备份清单
# 看容器实际吃到的资源上限(验证 compose 的 limits 生效了)
docker stats --no-stream workbuddy-portal
docker inspect -f 'CPU={{.HostConfig.NanoCpus}} MEM={{.HostConfig.Memory}} PIDS={{.HostConfig.PidsLimit}}' workbuddy-portal
```
---
## 五、路径 C:docker-compose 拉云端镜像部署
适合:NAS、服务器、**不想 clone 仓库**、只想尽快跑起来。镜像由 [第七节](#七把代码与镜像推到-gitea-注册表)
推送到 Gitea 注册表,这里只负责拉下来运行。
### 5.1 前置
- Docker + Compose v2
- 能访问 `git.iwali.top`(有 Let's Encrypt 通配证书,**HTTPS 直接可用,
不需要配 `insecure-registries`**)
### 5.2 建目录、写两个文件
```bash
mkdir -p /opt/workbuddy-portal && cd /opt/workbuddy-portal
```
**`docker-compose.yml`**(完整可用,不依赖仓库里的其它文件):
```yaml
name: workbuddy-portal
services:
portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
container_name: workbuddy-portal
restart: unless-stopped
init: true # tini 接管 PID 1,docker stop 能干净传到 python
# ---- 容器资源上限(对外提供服务时的第一道闸门)----
cpus: "${WB_CPUS:-1.0}" # SQLite 单写者,多给核也并行不起来
mem_limit: "${WB_MEM_LIMIT:-512m}"
memswap_limit: "${WB_MEM_LIMIT:-512m}" # 与 mem_limit 相等 = 禁用 swap
pids_limit: ${WB_PIDS_LIMIT:-256}
ulimits:
nofile: {soft: 4096, hard: 8192}
ports:
- "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848"
environment:
TZ: ${TZ:-Asia/Shanghai} # 决定调度时刻与所有日期口径
WB_HOST: 0.0.0.0
WB_PORT: "8848"
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
# 纯 HTTP 部署必须留 0,设成 1 会「登录成功又跳回登录页」
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0}
# 直接暴露公网必须留 0;只有自己的反代重写了 XFF 才置 1(见第六节)
WB_TRUST_PROXY: ${WB_TRUST_PROXY:-0}
WB_FORCE_HTTPS: ${WB_FORCE_HTTPS:-0}
WB_ACCESS_LOG: ${WB_ACCESS_LOG:-1}
WB_THREADS: ${WB_THREADS:-4}
WB_BACKUP_DIR: /app/backups
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
volumes:
- wb_data:/app/data # 数据正本 + instance.json + exports
- wb_logs:/app/logs
- wb_backups:/app/backups # 归档单独一个卷,别和正本同卷
healthcheck:
test: ["CMD", "python", "/app/docker/healthcheck.py"]
interval: 30s
timeout: 6s
start_period: 20s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
wb_data:
wb_logs:
wb_backups:
```
**`.env`**:
```bash
cp /dev/null .env
cat >> .env <<'EOF'
WB_BIND=0.0.0.0
WB_PORT=8848
TZ=Asia/Shanghai
# 首个管理员(只在数据库为空时生效)。
# 留空 = 程序生成随机口令并只在启动日志打印一次(没有 admin123 兜底了)。
WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD=换成你的强密码
WB_DISABLE_SCHEDULER=0
WB_COOKIE_SECURE=0
WB_TRUST_PROXY=0
WB_FORCE_HTTPS=0
WB_ACCESS_LOG=1
WB_THREADS=4
# 容器资源上限
WB_CPUS=1.0
WB_MEM_LIMIT=512m
WB_PIDS_LIMIT=256
EOF
chmod 600 .env # 里面有密码
```
### 5.3 登录注册表并启动
```bash
# 如果镜像是公开仓库,可以跳过 login
docker login git.iwali.top -u wangchuanli
# 密码填 Gitea Personal Access Token(「设置 → 应用 → 生成令牌」,勾 write:package)
docker compose pull
docker compose up -d
docker compose ps # 等 STATUS 变成 (healthy)
docker compose logs -f --tail=50
```
首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`):
1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建)。
没设 `WB_ADMIN_PASSWORD` 时,这一步会打印一次随机口令,格式长这样:
```
================= 管理员初始口令 =================
用 户 名:admin
初 始 口 令:xxxxxxxxxxxxxxxx
==================================================
```
**只有这一次机会**。之后想再看到,唯一的办法是删库重来或
`docker compose exec portal python manage.py passwd admin 新密码`。
2. 可选导入 —— `WB_IMPORT_CREDS=1` / `WB_IMPORT_XLSX=…` 才触发;
3. `exec manage.py serve` —— 交接给 waitress(线程数 `WB_THREADS`),调度线程就在这个进程里。
日志里看到 `[entrypoint] 启动 Web 服务(waitress)…` 就是起来了。
> 口令提示行是被容器启动日志带出来的,所以**别忘了加 `--tail`**
> (默认 `docker compose logs` 只给最近若干行;容器重启多轮后提示会被挤出去):
>
> ```bash
> docker compose logs portal | grep -A6 '管理员初始口令'
> ```
### 5.4 完成后的检查清单
```bash
# 1) 健康状态
docker compose ps # STATUS 应为 Up (healthy)
# 2) 时区正确
docker compose exec portal date # 应输出 CST / +0800
# 3) 数据库已就绪,且实例级配置齐全
docker compose exec portal python manage.py status
docker compose exec portal python manage.py users
# 4) 浏览器打开,用管理员登录 → 立刻改密码
# http://<服务器IP>:8848
```
> **登录后第一件事是改密码**:`admin/admin123` 是公开的默认值。
> 「个人中心 → 修改登录密码」,或 `docker compose exec portal python manage.py passwd admin 新密码`。
### 5.5 升级(本路径最省事)
```bash
# 改 docker-compose.yml 里的 tag,或
docker compose pull # 拉 latest
docker compose up -d # 重建容器;数据在命名卷里不受影响
docker compose exec portal python manage.py status
```
---
## 六、反向代理与 HTTPS
前面挂 nginx / Caddy / Traefik 时有三个要点,**第 3 条是 v1.4.0 新增的**,
漏了会以「登录限速误伤所有人」的形式出现:
**① nginx 侧透传真实 IP**
```nginx
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/ssl/certs/portal.crt;
ssl_certificate_key /etc/ssl/private/portal.key;
location / {
proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 透传真实 IP:登录失败限速、注册配额、验证码限速都按它计数
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 导出 CSV 已带 X-Accel-Buffering: no,这里关掉代理缓冲才能边查边吐
proxy_buffering off;
proxy_read_timeout 300s; # 采集 / 导出可能跑几分钟
}
}
```
**② 应用侧打开 `WB_TRUST_PROXY=1`(关键,别漏)**
程序**默认不信任** `X-Forwarded-For` —— 这是为了「直接暴露公网」时的安全:
XFF 的第一段是客户端自己填的,信了它,攻击者每次换一个伪造值就能绕过全部 IP 限速。
开启后程序取 XFF 里**最右侧**的合法 IP。最右侧是离你最近的那一跳(由你自己的代理写入),
客户端加不进去,所以下面两种 nginx 写法**都安全**:
| nginx 写法 | 效果 |
|---|---|
| `$proxy_add_x_forwarded_for` | 在客户端传来的值后面**追加**你的 `$remote_addr` → 链路完整,便于排查 |
| `$remote_addr` | **覆盖**成最近一跳 → 最保守,丢掉链路 |
真正不能做的是去信 XFF 里**最左边**那一段。
```bash
# .env
WB_TRUST_PROXY=1
```
> ⚠️ **漏了这一步的症状**:所有请求都以 `127.0.0.1`(代理的地址)计入限速,
> 于是**一个人失败 5 次,全站被锁 10 分钟**。这与「没透传 XFF」的表现一模一样,
> 所以两件事要一起改。
>
> 反过来,**不设反代却设了 `WB_TRUST_PROXY=1`** 是更危险的方向:
> 三道 IP 防线直接失效。拿不准就留 `0`。
**③ HTTPS 相关开关**
配好 HTTPS 后,把 `.env` 里的 `WB_COOKIE_SECURE` 改成 `1`(会话 Cookie 只走 HTTPS),
需要强制跳转再加 `WB_FORCE_HTTPS=1`。这两个任一为 `1` 时程序才下发 HSTS
—— 纯 HTTP 部署**不会**下发(下发会让浏览器强升 https,表现成白屏)。
改完都要 `docker compose up -d` 重建容器(环境变量在启动期读取)。
| 现象 | 原因 |
|---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For`,**或**忘了设 `WB_TRUST_PROXY=1` |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
| 访问 http 时白屏 / 一直跳转 | `WB_FORCE_HTTPS=1` 但实际没有 HTTPS 可用 |
---
## 七、把代码与镜像推到 Gitea 注册表
路径 C 需要的镜像就是在这里产出的。代码仓库与镜像的目标都是
`git.iwali.top/wangchuanli/workbuddy-portal`。
### 7.1 一条命令推代码 + 推镜像
先在 Gitea「设置 → 应用 → 生成令牌」创建 Access Token,勾选 `repo` + `write:package`:
```bash
export GITEA_TOKEN=<你的令牌>
tools/push-all.sh # 推 main 分支 + 镜像 latest(不打版本 tag)
tools/push-all.sh 1.5.0 # 另外打 git tag v1.5.0 与镜像 tag 1.5.0
```
脚本做的事:
1. **版本预检**(只有传了版本号时才做):从 `workbuddy_portal/__init__.py` 读
`__version__`,与传入的版本号比对,不一致**立刻退出**。放在最前面,免得代码和镜像
都推完了才发现「代码里写着 1.4.0,却当 1.5.0 发」——那种错误只能靠改 tag 补救。
2. `git push origin main` —— 用 `http.extraHeader` 传 Basic 认证,Token **只在环境变量里**,
不会写进 `.git/config`、URL 或 reflog;同时用 `-c credential.helper=` 屏蔽凭据助手
(否则在非交互 / 无桌面会话里 Git Credential Manager 会挂住等弹窗)。
3. `docker compose build` —— 镜像名本身就是注册表地址。
4. `docker login` + `docker push`(`--password-stdin`,Token 不进命令行历史)。
5. **`git tag -a vX.Y.Z` + 推送该 tag** —— 放在最后,**只有代码与镜像都推成功才落 tag**。
tag 一旦出现在远端就是「这个版本发过了」的公开声明,不该先于产物出现。
已存在的 tag 只提示、不改写:指向哪个提交由历史决定,脚本不去偷偷换掉
「v1.5.0 到底是哪份代码」这个答案。
> **为什么必须有 git tag**:镜像 tag 只说明「注册表里有这个包」,不能回答
> 「这个包对应哪个提交」。只推镜像的话,远程仓库里永远是**只有软件包、没有版本 tag**,
> 事后想 checkout 某个版本的代码只能靠翻 CHANGELOG 猜。
> v1.5.0 之前的三个版本(1.2.0 / 1.3.0 / 1.4.0)已按各自提交的 `__version__` 补打了
> `v1.2.0` / `v1.3.0` / `v1.4.0` 注解 tag;1.1.0 没有对应的提交,故未补。
> 不用脚本、手工推也行:`git push origin main` 弹出凭据窗口时,用户名填 `wangchuanli`,
> **密码处填 Access Token**(不是网页登录密码)。
### 7.2 传输协议:默认走 HTTPS
`git.iwali.top` 有 **Let's Encrypt 通配证书(`*.iwali.top`)**:
```bash
curl -s -o /dev/null -w "%{http_code}\n" https://git.iwali.top/api/v1/version # 200
curl -s -o /dev/null -w "%{http_code}\n" https://git.iwali.top/v2/ # 401(需认证,正常)
```
所以 git 远端用 `https://`,镜像名就是 `git.iwali.top/…`,Docker 默认按 HTTPS 访问,
**无需任何额外配置**。
> 只有在 Gitea 前面**没有** TLS 终止(纯 `http://`)时,才需要在 daemon.json 里加
> `{"insecure-registries": ["git.iwali.top"]}`。明文传输会让 Token 暴露在网络里,
> **能走 HTTPS 就不要开这个口子**。
### 7.3 手工构建与推送
```bash
docker login git.iwali.top -u wangchuanli
# 密码用 Personal Access Token,勾 write:package
docker compose build
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
```
### 7.4 验证远端
```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.5.0
# 或
curl -s -u wangchuanli:TOKEN \
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
```
镜像与 **git tag 要分别核验** —— 两者是两套东西,只推镜像时 git 侧会静默地什么都没有:
```bash
# 远端有哪些版本 tag(读权限即可,不需要 Token)
git ls-remote --tags origin
# 标签指向哪个提交(^(commit) 前缀可看到标签解引用后的提交)
git ls-remote --tags origin 'refs/tags/*^{}'
# 本地核对「版本号 ↔ 提交」是否对得上
git for-each-ref refs/tags --format="%(refname:short) -> %(*objectname:short) %(*subject)"
```
---
## 八、备份与恢复
### 8.1 备份什么
数据都在命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`):
| 文件 | 重要性 | 说明 |
|---|---|---|
| `usage.sqlite` | ★★★ | **数据正本**。丢了要重新采集,且**官网窗口之外的数据永久丢失** |
| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷**(或用 8.2 的方式先 checkpoint) |
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** |
| `exports/*.csv` | ★ | 导出快照,可再生 |
| `wb_logs` 卷 | ☆ | 排错用,可再生 |
| `wb_backups` 卷(`/app/backups`) | ★★★ | 备份归档 `usage-<时间戳>.zip`。**里面含一份 `instance.json`** —— 所以它的机密等级和密钥文件完全相同 |
`.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。
> **`instance.json` 为什么必须跟着备份走**:`cookie_key` 在里面,而各账号的 Cookie
> 是用它加密后入库的。只备库不备它,恢复出来的库能读,但**所有账号的 Cookie 都会
> 显示「无法解密」,需要各自重填**。所以 v1.4.0 的归档里默认打包了它
> (恢复时可以关掉,见 [8.3](#83-恢复))。
容器里归档落在**独立的命名卷** `workbuddy-portal_wb_backups`(对应 `/app/backups`),
**刻意不与 `data/` 同卷**:`docker compose down -v` 会把同卷的正本与副本一起删掉 ——
那正好是最需要备份的时刻。裸机部署下就是工程目录里的 `backups/`。
> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 [4.5](#45-数据落点命名卷不是绑定挂载) 与 [11.5](#115-windows-绑定挂载的坑容器打不开数据库))。
### 8.2 备份
**方式 A:用内置备份(推荐,v1.4.0 起)**
三种触发方式,产出的是同一种归档:
| 方式 | 怎么做 |
|---|---|
| 自动 | 「备份管理」页开 `backup_enabled`,设周期(默认 24 小时)与保留份数(默认 7)。调度线程每 20 秒检查一次,**有采集在跑就跳过**(不跟采集抢磁盘),到期自动打一份并按份数清理最旧的 |
| 页面 | 「备份管理 → 立即备份」,列表里可**下载**(拿 zip)、**恢复**、**删除** |
| 命令行 | `docker compose exec portal python manage.py backup --note "升级前"` |
```bash
# 列出已有备份(大小 / 条数 / 积分 / 来源 / 时间),--prune 顺便清理
docker compose exec portal python manage.py backups
docker compose exec portal python manage.py backups --prune --keep 5
# 把归档拿到宿主机(备份留在容器里不算备份)
docker compose cp portal:/app/backups/. ./backups/
```
> **归档里的快照不是 `cp` 出来的**:走的是 SQLite 在线备份 API(`Connection.backup`),
> 按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
> 直接 `cp data/usage.sqlite` 可能缺最近一段数据而**不报错**,这是最容易被忽略的差别。
**方式 B:整体打包命名卷(升级前做,最完整)**
```bash
docker compose stop portal
docker run --rm \
-v workbuddy-portal_wb_data:/data:ro \
-v "$PWD/backups":/backup \
alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data .
docker compose start portal
```
**方式 C:逻辑导出(跨版本最安全,可读性最好)**
```bash
docker compose exec portal python manage.py export-csv /app/data/exports
docker compose cp portal:/app/data/exports/. ./backups/exports/
```
建议:**方式 A 日常自动跑**(并定期把 zip 下载到容器之外/异地)、
方式 B 在**升级前**跑、方式 C 每季度跑一次。
裸机部署把上面命令里的 `docker compose exec portal` 去掉即可。
> ⚠️ **备份留在同一台机器上,只防「改错了」,不防「机器没了」**。
> 内置备份负责「本机有历史版本可回滚」,**异地那一份要你自己安排**
> (同步 `backups/` 到 NAS / 对象存储 / 另一台机器)。
### 8.3 恢复
**方式 A:用内置恢复(推荐)**
页面:「备份管理」→ 找到那一份 → 点「恢复」→ 先弹一次确认(是否连 `instance.json`
一起回滚)→ 再弹一次最终确认。
```bash
# 命令行:不加 --yes 只打印「将要发生什么」,不会动数据
docker compose exec portal python manage.py restore usage-20260916-151043.zip
docker compose exec portal python manage.py restore usage-20260916-151043.zip --yes
```
它做的事,按顺序:
1. **先校验**归档(格式版本 / 条目齐全 / `PRAGMA integrity_check` / 库结构版本不高过当前程序)。
验不过就**一个字节都不动**。
2. **再给当前库自动打一份 `pre-restore` 快照**(`trigger=pre-restore`,在备份列表里能看到)。
恢复错了你还能回去 —— 这是恢复链路的兜底。
3. 解包 → 在**临时库**上先把结构迁移到当前版本 → 在一个 `BEGIN IMMEDIATE` 事务里
**整表替换** `users` / `settings` / `usage_records` / `collect_runs` / `audit_log` / `captchas`。
4. 把归档里的 `instance.json` 覆盖回来(原文件先另存 `.pre-restore-<时间戳>`)。
5. 让**所有账号的会话立即失效**(`session_ver` 全体 +1)—— 恢复是全局性事件,
旧会话描述的账号与权限可能已经被整个换掉了。
> **为什么是「整表替换」而不是「换文件」**:换文件需要停机、且要求没人持有文件句柄
> (Windows 上会直接失败);整表替换在一个事务里完成,中途出错就是一次回滚,
> 而且**老版本的备份(`user_version` 2 / 3)也能恢复** —— 迁移在临时库里先做完。
> 另外它按**列名交集**搬数据,不是 `SELECT *`:老库的列是 `ALTER` 追加的,
> 顺序与新库建表语句不一定一致,`SELECT *` 会**静默错位**(字段整体串位而值都「合法」)。
不想连密钥一起回滚(例如只想把数据退回去、保留本机当前的 `cookie_key`):
```bash
docker compose exec portal python manage.py restore <文件名> --yes --no-instance
```
**方式 B:整体换卷(容器起不来时的兜底)**
```bash
docker compose down
# 把备份灌回命名卷(先清空,避免新旧 WAL 混用损坏数据)
docker volume rm workbuddy-portal_wb_data
docker volume create workbuddy-portal_wb_data
docker run --rm \
-v workbuddy-portal_wb_data:/to \
-v "$PWD/backups":/from:ro \
alpine:3.20 sh -c 'cp -a /from/. /to/'
docker compose up -d
docker compose exec portal python manage.py stats # 核对条数与积分
```
> **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。
> 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。
### 8.4 迁移:从绑定挂载换到命名卷
如果之前用了 `docker-compose.hostdir.yml`(或旧版把 `./data` 直接挂进去):
```bash
docker compose down
docker volume create workbuddy-portal_wb_data
docker run --rm \
-v workbuddy-portal_wb_data:/to \
-v "$PWD/data":/from:ro \
alpine:3.20 sh -c 'cp -a /from/. /to/'
docker compose up -d # 不带 -f hostdir 叠加层
docker compose exec portal python manage.py stats
```
迁移完成后,宿主机的 `./data` 不再被容器使用,留作迁移前的冷备即可。
---
## 九、升级与回滚
### 升级前
1. **先备份**(见 [第八节](#八备份与恢复))。备份要**同时包含 `usage.sqlite` 与 `instance.json`**
—— 后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更(`主版本` 与标了
**变更(不兼容)** 的条目)。
3. 记下当前版本:`docker compose exec portal python -c "import workbuddy_portal;print(workbuddy_portal.__version__)"`
### Docker
```bash
git pull # 路径 C 跳过这步
docker compose build # 路径 C 改用 docker compose pull
docker compose up -d # 重建容器;数据在命名卷里不受影响
docker compose exec portal python manage.py status
```
### 裸机
```bash
git pull
.venv/bin/pip install -r requirements.txt
sudo systemctl restart workbuddy-portal
```
### 回滚
```bash
# Docker:把 .env 的 WB_IMAGE 或 compose 里的 tag 指回旧版本
docker compose up -d --no-build
# 裸机
git checkout <旧版本 tag>
.venv/bin/pip install -r requirements.txt
sudo systemctl restart workbuddy-portal
```
> ### ⚠️ 回滚数据库前先看 `user_version`
> 迁移由 `PRAGMA user_version` 驱动,每次结构变更都会 +1。**回滚到更老的代码**时,
> 服务会认为库"版本过高"而拒绝启动(或反之重跑迁移)。回滚代码的同时要把库一起回滚,
> 用升级前那份快照。
>
> 想确认当前库结构版本:
> ```bash
> docker compose exec portal python -c "
> from workbuddy_portal import db; c = db.connect()
> print('user_version =', c.execute('PRAGMA user_version').fetchone()[0])"
> ```
### 迁移历史
| 版本跨度 | `user_version` | 自动做了什么 | 要手工介入吗 |
|---|---|---|---|
| **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 |
| **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 |
| **1.3.0 → 1.4.0**(备份 + 对外加固) | 3 → 4 | 只做两件事:`users` 加一列 `session_ver`(非空、默认 0)、建 `backups` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 |
| **1.4.0 → 1.5.0**(界面改版 + 公网加固) | 4 → 4 | **没有任何库结构变更**,`init_db()` 只做幂等校验。改动都在代码与界面上 | 不需要 |
四次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
```bash
docker compose logs portal | grep -iE "迁移|migrat|提升|promote"
docker compose exec portal python manage.py users # 账号 / 角色 / 数据量 / 凭证状态
docker compose exec portal python manage.py stats # 各账号条数与积分,核对零丢失
```
> 升级后请**确认 Cookie 能解密**:登录后打开「配置管理 → 我的云端凭证」,
> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「**无法解密**」,说明
> `instance.json` 不匹配,各自重新粘贴一次即可。
---
## 十、日常巡检
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录;确认「备份管理」里今天/昨天有一份 `auto` 备份 |
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**);`docker stats --no-stream` 看一眼内存有没有贴着 512m 上限 |
| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);**把最新归档下载到容器之外**;跑一次备份恢复演练(见下) |
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用与 `wb_backups` 卷的体积 |
一键体检:
```bash
docker compose exec portal python manage.py status # 调度与最近采集
docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细
docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态
docker compose exec portal python manage.py backups # 备份份数 / 占用 / 来源 / 下次自动备份
docker stats --no-stream workbuddy-portal # 实际 CPU / 内存占用
```
**备份恢复演练(每月一次,五分钟)**
不演练的备份等于没有备份 —— 只有在真的恢复过一次之后,你才知道那份 zip 是好的:
```bash
# 1) 随便挑一份归档,先只看它校验是否通过(不加 --yes,不会动数据)
docker compose exec portal python manage.py restore <文件名>
# 2) 真要演练恢复:确认当前库条数 → 恢复 → 再确认条数一致
docker compose exec portal python manage.py stats | head -3
docker compose exec portal python manage.py restore <文件名> --yes
docker compose exec portal python manage.py stats | head -3
```
> 恢复是**幂等且可回退**的:它会在动手前自动给你当前库打一份 `pre-restore` 快照,
> 恢复错了就再恢复回那一份。演练完记得 `backups --prune --keep N` 把多出来的清掉。
磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3,
每份归档约为数据库体积的 1/5(zip 压缩后)。增长慢,但**年度归档后记得对旧数据做取舍**
(本项目不做自动清理,数据只增不减;备份会按 `backup_keep` 自动清理)。
### 自检脚本(开发者向)
仓库自带三层验证,改完代码或升级后可以跑:
```bash
.venv/bin/python tools/check_docs.py # 文档自检:链接 / 锚点 / 图片 / 绝对路径泄漏 / 版本一致
.venv/bin/python tools/smoke.py # 215 项:库层 + 页面渲染 + 权限模型(不启服务)
.venv/bin/python tools/check_live.py # 122 项:真实 HTTP,含 CSRF / 开放重定向 / 验证码
.venv/bin/python tools/check_live.py --as alice:密码 # 追加普通账号越权验收
```
> `smoke.py` 会**对真实库做写入测试**(哨兵值用完即还原)。跑之前先备份,
> 或在示例库上跑:`python tools/demo_data.py` 会另建一份 `data/demo/usage.sqlite`。
>
> ⚠️ 在示例库上跑 `check_live.py` 时**必须显式加 `--db data/demo/usage.sqlite`** ——
> 它的 `--db` 默认值是真实的 `data/usage.sqlite`,不传会从错误的库里取验证码答案,
> 表现为「登录失败」加一串与真实原因无关的断言失败。
---
## 十一、排错
### 11.1 容器起来了但页面打不开
```bash
docker compose ps # 看 STATUS 是否 (healthy)
docker compose logs --tail=100
```
| 症状 | 排查 |
|---|---|
| `STATUS` 是 `Restarting` | 看日志里的 Python traceback;多半是 `data/` 权限或端口冲突 |
| `unhealthy` 但 `Up` | 健康检查打 `/login` 失败:`docker compose exec portal python /app/docker/healthcheck.py` 看具体报错 |
| 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` |
| 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 |
### 11.2 登录成功却立刻又跳回登录页
几乎一定是 `WB_COOKIE_SECURE` 被设成了 `1`,而你在用 **HTTP** 访问。
会话 Cookie 带 `Secure` 属性后浏览器只在 HTTPS 下回传;服务端每次都收不到会话,
就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」。
```bash
# .env
WB_COOKIE_SECURE=0
docker compose up -d # 环境变量,必须 up -d,restart 不生效
```
### 11.3 普通账号看不到「日志管理」/「用户管理」
**这是设计如此,不是 bug。** 见 [用户指南 · 权限与数据边界](USER-GUIDE.md#三权限与数据边界)。
| 入口 | 普通账号 |
|---|---|
| `/logs`、`/logs/tail` | 403(导航里不显示) |
| `/users`、`/api/users` | 403(导航里不显示) |
| `/backups`、`/api/backups*`(含**下载**与**恢复**) | 403(导航里不显示) |
| 任务管理页的调度表单 | 只读(时刻由管理员统一设定) |
| 配置管理页的采集参数 | 只读 |
| 本人的 Cookie / User-Agent | **可读写**(唯一可改的配置) |
| 「个人中心 → 导出我的全部数据」 | **可用**(只含本人数据,**不含 Cookie 明文**) |
改权限:`docker compose exec portal python manage.py passwd alice 密码 --role admin`。
### 11.4 `exec format error` / `no such file or directory`(entrypoint)
`docker/entrypoint.sh` 被 CRLF 污染了。仓库有 `.gitattributes` 强制 `*.sh` 为 LF;
若手工传过文件:
```bash
python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.replace(b'\r\n',b'\n'))"
```
### 11.5 Windows 绑定挂载的坑:容器打不开数据库
**症状**:容器起来时正常,跑着跑着 `/`、`/records` 全部 500,应用日志里是
```
File "/app/workbuddy_portal/db.py", line 33, in connect
conn.execute("PRAGMA journal_mode=WAL")
sqlite3.OperationalError: unable to open database file
```
**根因**:Windows + Docker Desktop 的绑定挂载走 **9p**(`mount` 里能看到
`type 9p (…aname=drvfs;path=C:\…)`)。9p 本身支持 WAL(在上面新建库没问题),
但**当宿主的 Windows 进程打开过这个 WAL 库之后**,容器侧缓存的 `-shm` 映射就失效了,
下一次连接无法重建共享内存文件,于是打不开数据库。
**最小复现**(2026-09-14 实测):
```bash
docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d
docker compose exec portal python -c \
"import sqlite3;print(sqlite3.connect('/app/data/usage.sqlite').execute('select count(*) from usage_records').fetchone())"
# -> (944,) 容器侧正常
python manage.py stats # 宿主侧随手跑一次「纯读」的 CLI
# -> 存档:944 条 …
docker compose exec portal python -c \
"import sqlite3;sqlite3.connect('/app/data/usage.sqlite')"
# -> sqlite3.OperationalError: unable to open database file
```
关键点:**纯读也会触发**,而且**宿主进程退出后容器不会自愈** —— 只有重启容器才恢复。
**处理**:
1. **根治(推荐)**:不要用 `hostdir` 叠加层,直接用默认的**命名卷**。
容器独占 `/app/data`,宿主侧一律通过 `docker compose exec portal python manage.py …` 操作。
2. **临时**:`docker compose restart portal` 立刻恢复,但下一次宿主访问会再坏一次。
3. **想在宿主侧看数据**:用 [8.2](#82-备份) 的方式导出到宿主目录再看,不要直接连库。
> Linux 宿主机上不存在这个问题(bind mount 与容器是同一文件系统),
> 所以 `docker-compose.hostdir.yml` 对 Linux 是安全的。
### 11.6 时区不对导致日期错位
```bash
docker compose exec portal date # 应该输出 CST / +0800
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,改完 `docker compose up -d` 重建容器
(`TZ` 是环境变量,`restart` 不生效)。裸机部署确认系统时区。
### 11.7 数据库相关
| 报错 | 原因 / 处理 |
|---|---|
| `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` |
| `unable to open database file`(**Windows + Docker Desktop**) | 见 [11.5](#115-windows-绑定挂载的坑容器打不开数据库);根治办法是改用命名卷 |
| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) |
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 |
| 库体积异常大 | `manage.py vacuum` 回收空间 |
### 11.8 采集相关
| 报错 | 处理 |
|---|---|
| `cookie_expired` / `401` | 让**该账号本人**重新获取 Cookie 填进「配置管理」,然后按区间补采 |
| `no_cookie` | 该账号还没配 Cookie(新注册的账号都属于这种),本人粘贴一次即可 |
| `cookie_broken` | 密文解不开(`instance.json` 被换过),本人重新粘贴一次 |
| `TLS` / `SSLError` | 企业代理 / 自签证书场景才临时把 `ssl_verify` 设为 `0`;否则保持开启 |
| 返回 `409 busy` | 正常 —— 已有采集在跑,等它结束 |
| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 |
| 多副本重复采集 | 除第一份外全部设 `WB_DISABLE_SCHEDULER=1` |
### 11.9 想临时关掉自动采集
「任务管理 → 取消勾选『启用调度』→ 保存」(**仅管理员**)。或者临时改环境变量:
```bash
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
```
> 注意这只关调度。手动采集、`manage.py collect`、**自动备份**都还在跑
> (自动备份由调度线程触发,所以关掉调度后自动备份也停了 ——
> 停调度期间请手动点几次「立即备份」)。
### 11.10 拿不到管理员初始口令
**症状**:第一次 `docker compose up -d` 之后想登录,不知道密码;日志里也找不到那行提示。
原因通常是**没设 `WB_ADMIN_PASSWORD`**(v1.4.0 起没有默认口令),而提示只在
「首次初始化那一刻」打印一次。依次试:
```bash
# 1) 翻日志(默认只给最近若干行,容器重启多轮后提示可能已被挤出,加 --tail 或直接 grep)
docker compose logs portal | grep -A6 '管理员初始口令'
docker compose logs --tail=2000 portal | grep -A6 '口令'
# 2) 如果管理员早就建好了,那行不会再出现 —— 直接重置:
docker compose exec portal python manage.py passwd admin '一个新的强密码'
# 3) 或者看数据库里到底有没有账号
docker compose exec portal python manage.py users
```
> **口令找不回来是有意的**:库里只有 PBKDF2 散列,明文只在生成时打印那一次、
> 不落盘。所以别指望「再查一次」——重置是正道。
> 下次部署记得在 `.env` 里显式写 `WB_ADMIN_PASSWORD`。
>
> 如果 `manage.py users` 显示**一个账号都没有**,说明初始化那步失败了,
> 去 `docker compose logs portal | grep -iE "init|error|traceback"` 看真实原因
> (最常见是数据卷权限,见 [4.5](#45-数据落点命名卷不是绑定挂载))。
### 11.11 备份相关
| 症状 | 原因 / 处理 |
|---|---|
| `/app/backups` 里是空的,但「备份管理」页说有 N 份 | 索引与实际磁盘不一致。页面每次打开都会 `sync_index()` 双向对齐,刷新一次即可;命令行用 `manage.py backups` 也会重建索引 |
| 列表里某一份标着「文件已不存在」 | 被手工删过(或恢复前的临时目录被清)。索引**故意保留这一行**,是为了留下「这里曾有过一份」的痕迹;用 `backups --prune` 或页面上的删除把它清掉 |
| 备份失败,日志里没有明显报错 | 自动备份的失败会记 `log.error`,看 `docker compose logs portal \| grep -i 备份`。常见原因是磁盘满或卷权限 |
| 恢复时提示「有采集任务正在运行」 | 设计如此:恢复要持采集锁。等当前采集结束(`manage.py status` 看互斥锁),或先停调度 |
| 恢复后所有人都被踢下线 | **设计如此**。恢复会让全体 `session_ver` +1 —— 归档里的会话版本与旧 Cookie 可能恰好相等,那样旧会话会带着「已被替换掉的账号与权限」继续用 |
| 恢复后所有 Cookie 显示「无法解密」 | 恢复时选了「不恢复 instance.json」,而两边的 `cookie_key` 不同。重新走一次恢复并带上 `instance.json`(默认会带),或让各账号重填 Cookie |
| 升级前想留一份最完整的 | 用 [8.2](#82-备份) 的方式 B(整体打包命名卷),它连 `exports/` 与 `instance.json` 一起包 |
| 想改保留份数 / 关掉自动备份 | 「备份管理 → 自动备份设置」里的 `backup_enabled` / `backup_interval_hours` / `backup_keep`(实例级,仅管理员) |
---
## 十二、配置项速查
### 12.1 数据库里的配置(改完立即生效,不用重启)
表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。
**v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。
其余全部是**实例级**(`GLOBAL_KEYS`,v1.4.0 起共 **23** 个),只有管理员能改。
写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。
| 键 | 默认 | 作用域 | 谁能改 | 说明 |
|---|---|---|---|---|
| `cookie` | 空 | 个人级 | **所有登录用户**(仅本人) | 账号凭证,**密文入库**;页面只回「长度 + 结尾 4 位」 |
| `user_agent` | Chrome UA | 个人级 | **所有登录用户**(仅本人) | 与 Cookie 同源更稳。**不参与实例级回落**(回落 = 串号越权) |
| `api_base` | 官方地址 | 实例级 | 仅管理员 | 接口基址(走镜像 / 代理时改) |
| `api_path` | `/billing/meter/get-user-request-usage` | 实例级 | 仅管理员 | 接口路径 |
| `allow_register` | `1` | 实例级 | 仅管理员 | 是否开放自助注册 |
| `register_max_per_ip` | `3` | 实例级 | 仅管理员 | 同一 IP 每日注册上限(1~50) |
| `captcha_policy` | `always` | 实例级 | 仅管理员 | `always` / `adaptive` / `off` |
| `captcha_length` | `4` | 实例级 | 仅管理员 | 验证码位数(4~6) |
| `schedule_enabled` | `1` | 实例级 | 仅管理员 | 调度总开关 |
| `schedule_times` | `09:00,17:00` | 实例级 | 仅管理员 | 每日时刻,逗号分隔,本地时区 |
| `catch_up` | `1` | 实例级 | 仅管理员 | 启动补跑开关 |
| `catch_up_grace_hours` | `12` | 实例级 | 仅管理员 | 补跑宽限期(1~168 小时) |
| `max_schedule_slots_per_day` | `6` | 实例级 | 仅管理员 | **每日调度时刻数上限**(1~12,代码硬顶 12)。挡住「填 200 个时刻」 |
| `collect_min_interval_seconds` | `60` | 实例级 | 仅管理员 | **同一账号两次手动采集的最小间隔**(0~3600 秒);期间再点会 `429` |
| `collect_max_range_days` | `31` | 实例级 | 仅管理员 | **单次采集的最长跨度**(1~31 天,代码硬顶 31 = 1 个月)。改大也突破不了硬顶 |
| `backup_enabled` | `1` | 实例级 | 仅管理员 | 是否开启自动备份 |
| `backup_interval_hours` | `24` | 实例级 | 仅管理员 | 备份周期(1~720 小时) |
| `backup_keep` | `7` | 实例级 | 仅管理员 | 保留最近几份,超出的删最旧的(1~100) |
| `page_size` | `200` | 实例级 | 仅管理员 | 采集单页条数(20~1000) |
| `rewind_minutes` | `2` | 实例级 | 仅管理员 | 断点回退分钟数(0~120) |
| `drift_tolerance_minutes` | `5` | 实例级 | 仅管理员 | 云端时间漂移告警阈值(0~720) |
| `max_prompt` | `2048` | 实例级 | 仅管理员 | Prompt 入库截断长度,`0` = 不截断(0~20000) |
| `verify_days` | `0` | 实例级 | 仅管理员 | 采集后整日校验天数(0~90) |
| `timeout` | `30` | 实例级 | 仅管理员 | HTTP 超时秒数(5~300) |
| `ssl_verify` | `1` | 实例级 | 仅管理员 | 校验云端 HTTPS 证书 |
**为什么调度与采集参数也放实例级**,而不是「个人级但只有管理员能写」:
> 如果它们只写在管理员自己的 `user_id` 下,其它账号读取时会回落到 `DEFAULTS`,
> **管理员改的值对别人完全不生效** —— 那才是真正的坑。统一放实例级,
> 语义是「一台部署一套采集与调度策略」,读起来也简单。
>
> `set_setting()` 还强制把全局键重定向到 `user_id=0`,从结构上消除
> 「管理员改了只有自己生效」这类 bug。
**读配置有三级回落**:个人级 → 实例级 → `config.DEFAULTS`(`cookie` / `user_agent` 例外,
它们永不回落)。
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒 ——
接口不能用来往 `settings` 表里塞任意键。
> 另一个容易混的点:`slot:<时刻>` 这类**调度簿记键**仍是**个人级**的 ——
> 每个账号各自记「今天这个槽位跑过没」。**时刻本身是实例级,簿记是个人级**,
> 这两个千万别一起改。
### 12.2 环境变量(启动期,改了要重建容器)
| 变量 | 默认 | 说明 |
|---|---|---|
| `TZ` | 容器 `Asia/Shanghai` | 时区,影响所有日期口径与调度时刻 |
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址(仅 compose 用) |
| `WB_HOST` / `WB_PORT` | `0.0.0.0` / `8848` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | `/app/data` / `/app/logs` / `<数据目录>/usage.sqlite` | 路径覆盖 |
| `WB_BACKUP_DIR` | `/app/backups` | 备份归档落点(**不要放进 `data/`**,见 [8.1](#81-备份什么)) |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`)。**同时是下发 HSTS 的开关之一** |
| `WB_TRUST_PROXY` | `0` | `1` = 信任 `X-Forwarded-For`(取**最右侧**合法 IP)。**直接暴露公网必须留 0**,见 [第六节](#六反向代理与-https) |
| `WB_FORCE_HTTPS` | `0` | `1` = 非 HTTPS 的 GET/HEAD 跳转(POST 不跳) |
| `WB_ACCESS_LOG` | `1` | 记访问日志到 `logs/app.log`(跳过静态资源与验证码图) |
| `WB_THREADS` | `4`(compose)/ `8`(代码默认) | waitress 线程数 = 单实例并发处理慢请求的上限 |
| `WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT` | `1.0` / `512m` / `256` | 容器资源上限(仅 compose 用,见 [4.4](#44-env-主要变量)) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(**自动备份也会一起停**) |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**)。留空则随机生成、只在启动日志打印一次 |
| `WB_IMPORT_CREDS` | `0` | 启动时从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时导入指定路径的官网 xlsx |
| `WB_IMAGE` | Gitea 地址 | 镜像名(仅 compose 用) |
| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` / `WB_HOST_BACKUP_DIR` | `./data` / `./logs` / `./backups` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) |
### 12.3 `manage.py` 子命令速查
| 命令 | 作用 |
|---|---|
| `init [--user U] [--password P]` | 初始化 / 迁移数据库,创建首个管理员(幂等) |
| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度)。`--debug` **只允许绑回环地址**(Werkzeug 调试器可执行任意代码) |
| `collect [-u U]` | 执行一次增量采集(不传 `-u` 则逐个启用账号) |
| `migrate-csv [PATH] [-u U]` | 从 v1.0 的 CSV 导入(只读,可重复) |
| `import-xlsx PATH [-u U]` | 从官网导出的 xlsx 导入 |
| `fill-prompt [-u U]` | 补全缺失的 `User Prompt` |
| `export-csv [PATH] [-u U]` | 导出 CSV |
| `stats [-u U]` | 存档概况(先全库概览,再给指定账号明细) |
| `users` | 列出所有账号及数据量 / 凭证状态 |
| `import-creds [-u U]` | 从 VSCode / Cursor / Trae 设置导入 Cookie / UA |
| `passwd USER [PASSWORD] [--role admin\|user] [--activate]` | 重置 / 创建账号 |
| `status` | 各账号的调度与最近采集状态 |
| `vacuum` | 整理数据库(checkpoint + VACUUM) |
| `backup [--note 说明]` | 立即打一份备份(所有 `data/*.sqlite` + `instance.json` → zip),并按保留份数清理最旧的 |
| `backups [--prune] [--keep N]` | 列出备份(大小 / 条数 / 积分 / 来源 / 时间);`--prune` 顺便清理 |
| `restore <文件名> [--yes] [--no-instance]` | **从备份恢复**(整表替换)。**不加 `--yes` 只打印将要发生什么**;`--no-instance` = 不覆盖 `instance.json` |
Docker 部署下前面加 `docker compose exec portal`。
---
## 十三、安全与隐私基线
> 面向**要把这个实例挂到公网 / 开放注册**的部署者。
> 代码层已经替你关掉的那部分不需要配置;需要**你决定**的部分在 13.2 与 13.4 节。
### 13.1 代码层已经做到的(不用配置,改坏了反而有风险)
| 威胁 | 处理方式 | 入口 |
|---|---|---|
| 撞库 / 爆破 | 图形验证码 + **按来源 IP 硬锁** + 用户名软退避 + 单 IP 尝试总量(40 次 / 5 分钟) | `security.py` |
| 用户名枚举 | 口令校验对不存在的账号也走一次**哑哈希**,两条路径耗时对齐;登录失败文案统一 | `security.login_ok` |
| 会话固定 / 劫持 | 登录时重建会话(顺带换掉 CSRF 与验证码 id);`HttpOnly` + `SameSite=Lax`;改密 / 重置 / 停用后 `session_ver` 立即作废旧会话 | `security.login_session` |
| CSRF | 所有非 GET 请求在 `before_request` 里统一校验;退出登录也改成 POST | `security.check_csrf` |
| XSS | Jinja 自动转义 + 前端手动 `esc()`(含 ECharts 的 HTML tooltip)+ CSP + `nosniff` | `security.CSP`、`dashboard/index.html` |
| 点击劫持 | `X-Frame-Options: DENY` + CSP `frame-ancestors 'none'` | `apply_security_headers` |
| 开放重定向 | `next=` 只允许站内相对路径 | `security.safe_next` |
| **响应头注入** | 导出文件名一律先收敛成 ASCII 安全名,再按 RFC 5987 附上原名 | `security.content_disposition` |
| **路径穿越** | 备份文件名走 `safe_name()`;导出 CSV 的账号名同样收敛(`manage.py passwd` 建号也补了用户名校验) | `backup.safe_name`、`collect.export_csv` |
| **内部信息泄漏** | 兜底异常只回一个事件号(完整堆栈进服务端日志);普通账号拿不到表名与库文件路径 | `web/api.py::_internal`、`query.manifest` |
| 资源耗尽 | 采集三道闸门(互斥 / 最小间隔 / 跨度上限)+ 重操作最小间隔 + **读接口限速(240 次 / 分钟 / 账号)** + 容器 `cpus`/`mem_limit`/`pids_limit` | `web/api.py`、`security.api_rate_ok`、`docker-compose.yml` |
| 凭证泄漏 | Cookie 静态加密入库;页面与接口只回「长度 + 尾 4 位」;导出与备份都不含明文 | `crypto.py`、`db.get_secret` |
| **落盘权限** | 进程 `umask 0077` + 目录 0700 / 文件 0600(SQLite 的 `-wal`/`-shm`、导出 CSV、备份 zip 一并覆盖) | `config.harden_process` |
| 调试器 RCE | `--debug` 只允许绑定回环地址,绑对外地址直接拒绝启动 | `manage.py cmd_serve` |
### 13.2 公网暴露前你必须自己做的
1. **上 HTTPS,并把开关一起打开**(缺一个就是半截配置):
```
WB_FORCE_HTTPS=1
WB_COOKIE_SECURE=1
# 反向代理终止 TLS 时还要:WB_TRUST_PROXY=1(且代理必须重写 XFF)
```
见 [第六节](#六反向代理与-https)。
2. **直连公网时 `WB_TRUST_PROXY` 必须留 0**。置 1 的前提是「**你自己的**反代会重写
`X-Forwarded-For`」;否则攻击者每换一个伪造的 XFF,验证码限速、注册配额、
登录锁定三道 IP 防线会同时失效。
3. **显式指定管理员口令**:`WB_ADMIN_PASSWORD=<强口令>`。留空时程序会生成随机口令并
只在启动日志里打印一次,忘了抄就只能 `manage.py passwd` 重置。
4. **决定要不要开放注册**:`allow_register`(默认 `1`)。开放就确认
`captcha_policy=always` 与 `register_max_per_ip` 的取值。
5. **别把备份卷和 `data/` 卷暴露出去**:备份 zip 里含**主密钥**(`instance.json`),
拿到它等于拿到全库凭证的明文。备份目录默认不在 `data/` 内,容器里挂的是独立卷。
6. **在应用前面再加一层限制**(可选但强烈建议):反代 / 云安全组做 IP 白名单或接 WAF。
应用层的限速是最后一道,不该是唯一一道。
### 13.3 隐私:本系统存了哪些个人数据
| 数据 | 存在哪 | 谁能看到 | 备注 |
|---|---|---|---|
| **对话正文**(`User Prompt`) | `usage_records.prompt`;管理员导出时落到 `data/exports/*.csv` | 仅本人(页面 / 导出);管理员在**备份归档**里也能拿到 | 本系统里最敏感的一类数据。入库按 `max_prompt` 截断(默认 2048 字符) |
| 云端凭证(Cookie / UA) | `settings.value`,**密文** | 仅本人,且只能看到「长度 + 尾 4 位」 | 主密钥在 `data/instance.json`(0600) |
| 账号资料 | `users`(用户名 / 显示名 / 邮箱) | 本人 + 管理员 | 邮箱选填 |
| 来源 IP | `users.register_ip`、`users.last_login_ip`、`audit_log.ip` | 仅管理员 | 用于每日注册配额与事后追责 |
| 采集运行日志 | `collect_runs.detail`、`logs/app.log` | 本人(自己的);`/logs` 整页仅管理员 | 访问日志只记路径,**不记 query string** —— 否则 `?q=<搜索词>` 会把 prompt 片段带进日志文件 |
三条数据边界由**服务端**强制,不靠界面隐藏:
- 普通账号的一切读写都带 `user_id = 当前账号`;越权写入直接 `400`,并记一条
`settings_rejected` 审计;
- 管理员在「用户管理」看得到账号列表与登录 IP,但**看不到任何人的用量与凭证**;
- 凭证明文只在「真正要拿它对外发请求」的那一刻解出来,不进日志、不进响应体。
### 13.4 保留期与删除:需要你自己定的部分
**代码刻意不做自动清理** —— 自动删数据比留数据危险得多。所以下面这些是部署者的事:
- `prompt` 与 `audit_log` 会一直留着。需要按时间裁剪时(**先 `manage.py backup`**):
```sql
-- 例:清掉 180 天前的对话正文(保留行与积分,只去正文)
UPDATE usage_records SET prompt='' WHERE day < date('now','-180 day');
-- 例:清掉 365 天前的审计
DELETE FROM audit_log WHERE at < date('now','-365 day');
```
改完用 `manage.py vacuum` 回收空间。
- **删除账号已经是「被遗忘权」的落地方式**:「用户管理 → 删除」默认**连同该账号的用量
数据与 Cookie 一起删除**;用户自己可以走「个人中心 → 导出我的全部数据」把数据带走。
- **导出文件的留存**:`data/exports/*.csv` 是对话正文的明文副本,没有谁会自动清。
容器里它在 `wb_data` 卷内 —— 保留多久由你决定,别让它一直躺在磁盘上。
### 13.5 上线前的自检
```bash
# 1) 应用层回归(第 9 节专门验「对外暴露面」:响应头收敛 / 异常不外泄 / 读接口限速)
python tools/smoke.py
# 2) 从外部看到的响应头
# 期望看到 CSP / Permissions-Policy / X-Frame-Options / nosniff / Referrer-Policy
curl -sI https://你的域名/login | grep -iE 'content-security|permissions-policy|x-frame|nosniff|referrer'
# 3) 确认没有把调试器挂出去(这条应当直接报错退出,而不是启动成功)
python manage.py serve --debug
```