feat: 新增备份恢复与公网加固

- 新增备份管理页与 API:在线快照、自动周期备份、按份数清理、下载、一键恢复(恢复前自动兜底)
- 新增 /profile/export,普通用户可导出本人全部数据(不含 Cookie 明文)
- 修复 X-Forwarded-For 可伪造导致三道 IP 防线失效,统一走 client_ip() 取客户端地址
- 取消 admin123 硬编码默认口令,留空则生成随机初始口令并仅打印一次
- .dockerignore 排除 backups/ 并加构建期断言,防止密钥随镜像分发
- 新增会话版本号,改密/停用/删除及恢复备份后其他会话立即失效
- 新增容器资源上限、采集跨度硬顶 31 天、重操作最小间隔与并发 409
- 新增访问日志、HSTS 条件下发、口令黑名单、验证码抗模板匹配、instance.json 0600
- 版本号升至 1.4.0,同步更新 README、SECURITY、.env.example 与 compose 配置
这个提交包含在:
2026-09-18 08:46:34 +08:00
父节点 1bf961f6b3
当前提交 3751dffef9
共修改 30 个文件,包含 3150 行新增和 241 行删除
+188
查看文件
@@ -8,6 +8,194 @@
---
## [1.4.0] — 2026-09-16
**主题:备份管理 · 对外提供服务的安全加固 · 资源与频率限制**
三件事:① 补齐「备份 / 恢复 / 导出本人数据」这条数据安全链路;② 修掉三类 P0 与
一批 P1;③ 让这个程序可以**安全地暴露到公网**——资源在容器层限制、任务频率在实例层
限制、采集跨度有硬上限。
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 3 → 4 时自动迁移,
只做一件事(`ALTER TABLE users ADD session_ver`,非空、默认 0)+建一张新表
`backups`。**无数据搬运、无键位变动**,可重复执行。
---
### 备份管理(新功能)
- **`workbuddy_portal/backup.py`(新模块)** —— 备份的全部逻辑
- **快照走 SQLite 在线备份 API**(`Connection.backup`),不是 `cp`。
它按页复制并持有读事务,所以**采集正在写的时候拿到的也是「某一时刻的完整库」**。
手工 `cp data/usage.sqlite` 做不到这点:`-wal` 里可能还有没落盘的帧,
拷出来的库会缺最近一段数据,而且**不报错**。
- **归档 = 一个 zip**:所有 `data/*.sqlite` + `manifest.json` + `instance.json`。
单文件、可校验、可搬到别的机器恢复。
- **`backup_keep` 保留份数**:超出后按时间删最旧的(只按**磁盘上真实存在**的算份数,
已经被手工删掉的条目不占名额)。
- **`backups` 表 + 磁盘双向对齐**:`sync_index()` 把磁盘上的归档登记进表、
把消失的标记 `missing=1`(不删行,保留「这里曾有过一份」的痕迹)。
**磁盘是事实来源**,表只是缓存 —— 手工拷进来/手工删掉都能被正确呈现。
- **管理员页面 `/backups`**(导航在「用户管理」前):KPI(份数 / 占用 / 自动备份状态 /
上次与下次)、自动备份设置表单、归档内容说明、备份列表(下载 / 恢复 / 删除)。
- **接口**(全部仅管理员):
`GET /api/backups`、`POST /api/backups`、`GET /api/backups/<filename>`(下载)、
`POST /api/backups/<filename>/restore`、`POST /api/backups/<filename>/delete`、
`POST /api/backups/prune`。
- **恢复的三条设计决定**(每条都对应一个会真的踩到的坑)
1. **SQL 级整表替换,不做文件 swap**。解包 → 临时库先迁移到当前 schema →
一个 `BEGIN IMMEDIATE` 事务里整表搬过去。好处:不需要停机、不依赖「没有别人
持有文件句柄」(Windows 上文件 swap 会因句柄占用直接失败)、备份是旧版本
(uv=2/3)也能恢复、中途失败就是一次回滚。
2. **列名交集而非 `SELECT *`**。老库的列是 `ALTER` 追加的,顺序与新库建表语句
不一定一致 —— `SELECT *` 会**静默错位**,字段整体串位而值都「合法」,
是最难查的一类数据损坏。
3. **恢复前自动打一份 `pre-restore` 快照**。恢复错了还能回到恢复之前。
- **恢复后让所有会话失效**:把所有账号的 `session_ver` +1。光靠「从归档里搬
`session_ver`」是不够的 —— 在打备份**之前**登录的那个人,他那张 Cookie 里的 `sv`
正好等于归档里的值,会话会「合法地」活下来,而它描述的账号与权限可能已经被
这次恢复整个换掉了。
- **`instance.json` 在归档里是必要的,不是顺手加的**:没有 `cookie_key` 就永远
解不开 `settings` 里的凭证密文,那样的「恢复」等于把所有人的 Cookie 弄丢。
代价是归档本身含密钥 ⇒ 它**永不入库、永不进镜像**(见下方 P0-3)。
恢复时可以 `include_instance=false` 只搬数据、保留本机当前密钥。
- **`safe_name()` 路径穿越收口**:下载与恢复接口都直接吃文件名,
这里做 basename 收敛 + 后缀 + 字符白名单(实测拦住 `../../etc/passwd`、
`x.txt`、`''`、`..`、`a b.zip`)。
- **`_extract_safely()` 防 zip slip**:归档可能是**外部给的**,
`extractall` 会因为条目名里的 `..` / 绝对路径写到目录之外。
- **普通用户导出本人全部数据**:`GET /profile/export`(任何登录用户,10 秒限速),
用 `SpooledTemporaryFile(max_size=16MB)` 边生成边下发,含 4 份 CSV/JSON
(使用记录 / 采集历史 / 操作审计 / 我的账号与配置)+ 说明。
**不含 Cookie 明文** —— 导出自己的数据不等于把凭证交出去。
- **CLI**:`manage.py backup [--note]` / `backups [--prune] [--keep N]` /
`restore <文件名> --yes`(破坏性操作必须显式 `--yes`,不加只打印将要发生什么)。
- **自动备份**:`backup_enabled` / `backup_interval_hours` / `backup_keep` 三个实例级键。
由 `scheduler.tick()` 每 20 秒检查一次(**有采集在跑就跳过**,不跟采集抢磁盘),
到期就打一份并按份数清理。首次部署无需等待 —— 库里没有自动备份记录时第一轮 tick 即触发。
### P0 修复
- **P0-1 `X-Forwarded-For` 可伪造 → 三道 IP 防线全废**
- 实测:修改前每次换一个伪造的 `X-Forwarded-For`,45 次验证码请求**全部放行**;
验证码限速、注册配额、登录锁定三道防线同时失效。
- 新增 **`security.client_ip()`** —— 全站**唯一**取客户端地址的入口。
默认**不信任** XFF,直接用 `remote_addr`;`WB_TRUST_PROXY=1` 时取**最右侧**合法 IP
(最近的一跳由你自己的代理写入,客户端伪造不了),含 `IPv4:port` 与 IPv6 处理。
- 原来散落在 `views.py` / `api.py` 的 6 处 `request.remote_addr` 全部改走它。
- **P0-2 默认口令 `admin123` 硬编码兜底**
- 现在 `WB_ADMIN_PASSWORD` 为空时,程序用 `secrets.token_urlsafe(12)` 生成随机口令,
**只在启动日志里打印一次**,且**不写进数据库**(审计日志里出现口令等于永久留档)。
`manage.py init` 会用醒目的方框打印它 —— 库里只有散列,日志一滚就再也拿不回来。
- `docker/entrypoint.sh` 里那句「默认密码是 admin123」的提示同时删除(它会给出一个
永远登录不上的口令)。
- **P0-3 `.dockerignore` 漏了 `backups/` → 凭证密文被打进镜像**
- `backups/` 里躺着 `usage.sqlite.bak-pre-v13`(4.5 MB 的**真实生产库**快照),
含 `settings` 的凭证密文与 `users` 的口令散列。而 `.dockerignore` 里没有任何
规则能匹配 `backups/` ⇒ `docker build` 会把它原样烤进镜像层,
**推一次镜像等于把整库密钥分发给所有能拉镜像的人**。
- 补 `backups/` 与 `data/demo/`;并在 Dockerfile 里加一条**构建期断言**:
`COPY` 之后若 `/app/backups` 非空就直接构建失败。
靠「记得改 .dockerignore」不可靠 —— 让构建自己拒绝。
注意这不能靠 `RUN rm` 补救:镜像层是只读叠加的,删掉只会多留一个含内容的中间层。
### P1 修复
- **采集与导出没有任何跨度上限与频率限制**:一个注册账号就能反复打 `/api/collect`
把云端与线程池占满。现在 `/api/collect` 有**三道闸门**:
① 采集锁存在 → `409`(**有任务在跑就不能新建任务**);
② `action_allowed("collect:uid", gap)` → `429`(默认最小间隔 60 秒);
③ 跨度超过上限 → `400`。
- **`COLLECT_MAX_RANGE_DAYS_HARD = 31` / `SCHEDULE_SLOTS_HARD_MAX = 12`**:
代码层**硬顶**。`collect_max_range_days` / `max_schedule_slots_per_day` 只是更严的
旋钮,**改大也突破不了** —— 上限必须由代码兜底,不能只靠写入校验。
历史脏数据、手工改库都过不去。(用户要求:「最长跨度的为 1 个月」)
- **`action_allowed(key, min_interval)` 通用重操作限速**:采集 / 导出 / 导出本人数据 /
`vacuum` / 补全 prompt / 重算 各有最小间隔(`{"fill-prompt":60, "export-csv":15,
"vacuum":120, "recount":5}`,采集与 CSV 导出见各自常量)。
- **`WB_COOKIE_SECURE` 默认 0、无 HSTS**:`_env_flag()` 统一解析布尔环境变量;
`apply_security_headers()` 在 **`COOKIE_SECURE or FORCE_HTTPS`** 时下发 HSTS
(**只在真正走 HTTPS 时下发** —— 纯 HTTP 部署下发会让浏览器强升 https,
表现成白屏,是个很难归因的故障)。
- **账号锁定可以被当武器**:知道用户名就能把对方锁死 10 分钟,而**管理员用户名在导航栏里
是公开的**。改成双维度、强度刻意不同:
IP 维度真锁(`MAX_LOGIN_FAILS` + `LOGIN_LOCK_MINUTES`),
用户名维度只做秒级递增退避(`USER_SOFT_THRESHOLD` / `USER_SOFT_CAP_SECONDS=60`)。
另外加**单 IP 登录尝试总量**(`LOGIN_ATTEMPTS_PER_IP=40` / `LOGIN_ATTEMPTS_WINDOW=300`,
**含成功**)挡住「慢慢撞、不触发失败阈值」的形态。
登录失败提示也从「剩余 N 次」改成「本来源连续失败 N 次」—— 不再给攻击者倒计时。
- **改密码不失效其他会话**:新增 `users.session_ver`(`DB_SCHEMA_VERSION` 3 → 4),
`current_user()` 每个请求把会话里的 `sv` 与库里比对,不等就丢会话。
改自己密码时会**把当前会话刷新到新版本**(否则改完立刻被自己踢下线)。
管理员重置口令 / 停用账号 / 删除账号同样 `bump_session_ver()` —— 停用立即生效,
不用等会话过期。
- **验证码强度不足**(字模在源码里、只整体放大 5 倍、无扭曲,容易被模板匹配):
改为让**同一字符两次渲染尽量不同** —— 逐字符随机旋转 ±22°、切变 ±0.32、缩放抖动、
波浪偏移、粗刷笔画(旋转时不断裂)、两色斜向渐变背景、噪点 46 → 70、压线 2~3 条。
实测同一验证码两次渲染字节差异 **76.9%**,字符仍可辨认。
- **`instance.json` 未设权限**:POSIX 上显式 `chmod 0600`(默认 umask 022 会留下 0644,
同机其他用户可读)。恢复时写回也走同一处理。
- **无访问日志**:waitress 自己不记 access log,出事无据可查。
新增 `security._access_log()`(跳过 `/static/` 与 `/captcha.png`,写进 `logs/app.log`),
由 `WB_ACCESS_LOG` 控制(默认开)。
- **`api_base` 可指向云元数据地址**:拦截 `169.254.169.254` /
`metadata.google.internal` / `[fd00:ec2::254]` —— 这是 SSRF 拿云上临时凭证最经典的一跳,
而没有任何合法采集场景需要它。
- **口令黑名单**:`WEAK_PASSWORDS`(34 个自动撞库字典的头几页)。
只在**设置/修改**口令时校验,登录不校验 —— 否则会把用老口令的存量用户挡在门外。
### 新增(配置项)
`GLOBAL_KEYS` 17 → **23** 键。新增的 6 个都是实例级:
| 键 | 默认 | 范围 | 说明 |
|---|---|---|---|
| `max_schedule_slots_per_day` | 6 | 1~12 | 每日调度时刻数上限(挡住「填 200 个时刻」) |
| `collect_min_interval_seconds` | 60 | 0~3600 | 同一账号两次手动采集的最小间隔 |
| `collect_max_range_days` | 31 | 1~31 | 单次采集的最长跨度(硬顶 31 天 = 1 个月) |
| `backup_enabled` | 1 | 0/1 | 是否开启自动备份 |
| `backup_interval_hours` | 24 | 1~720 | 备份周期 |
| `backup_keep` | 7 | 1~100 | 保留最近几份 |
新增环境变量:`WB_TRUST_PROXY`、`WB_FORCE_HTTPS`、`WB_ACCESS_LOG`、`WB_THREADS`、
`WB_BACKUP_DIR`、`WB_CPUS` / `WB_MEM_LIMIT` / `WB_PIDS_LIMIT`(compose 用)、
`WB_HOST_BACKUP_DIR`(叠加层用)。
### 部署与外网暴露
- **`BACKUP_DIR` 刻意不在 `data/` 里面**:容器里 `data/` 是数据卷,
`docker compose down -v` 会把正本与副本一起删 —— 那正好是最需要备份的时刻。
新增 `WB_BACKUP_DIR`(容器里 `/app/backups`)+ compose **命名卷 `wb_backups`**。
**绝不退回绑定挂载**(Windows 9p 下容器会 `unable to open database file`)。
- **容器资源上限**(用户要求「资源从容器上限制」):
`cpus: 1.0` / `mem_limit: 512m` / `memswap_limit` 与 `mem_limit` **相等**(= 禁用 swap,
这样超限会「被 OOM 杀掉」而不是「越来越慢」,后者更难查)/
`pids_limit: 256`(挡 fork 炸弹)/ `ulimits.nofile 4096:8192`
(SQLite 除主库外还持有 `-wal` `-shm`,恢复期还要开临时库 + `ATTACH` 源库)。
- **`manage.py serve` 的线程数改为 `config.THREADS`**(原为硬编码 8):
它决定单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复),是资源上限的一部分。
1 核配 8 线程容易出现「都在等 CPU」的假并发,容器默认给 4。
- 新增 `docker-compose.yml` 里 `WB_TRUST_PROXY` / `WB_FORCE_HTTPS` /
`WB_COOKIE_SECURE` 三者相邻并写明「必须一起决定」—— 拆开写很容易出现
「开了强制 HTTPS 却忘了 Secure」这类半截配置。
### 其它
- `scheduler.py` 的 `tick()` 末尾增加自动备份检查(在账号循环**之外**,因为它是
实例级、与具体账号无关)。
- `collect.py` 新增 `max_range_days(conn)` / `min_interval_seconds(conn)`:
接口、页面、`sync()` 三处共用**唯一口径**(此前会出现「页面显示的限值」与
「接口实际校验的限值」不是同一个数的情况)。
- `sync()` 因跨度上限收窄起点时打一条 `[warn] 请求跨度超过上限 %d 天,已自动收窄起点`。
- `backup.sync_index()` 现在从 manifest 里读真实的 `trigger` / `actor` / `note`,
不再一律标成 `external` —— 恢复前最需要判断的恰恰是「这份是自动备份、
还是我手工留的、还是恢复前系统自动存的那一份」。
- **新增页**:`/backups`(仅管理员)、`/profile/export`(任何登录用户)。
- **测试**:`tools/smoke.py` 与 `tools/check_live.py` 补备份链路、越权、
跨度上限、并发拒绝、会话失效断言。
---
## [1.3.0] — 2026-09-16
**主题:权限收敛 · 配置作用域统一 · 目录规范化**
+360 -79
查看文件
@@ -90,17 +90,26 @@ workbuddy-portal/
**① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。
```bash
# 默认是 admin / admin123 —— 局域网部署下必须改掉!
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` |
|---|---|---|
| 只本机用 | `127.0.0.1` | `0` |
| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) |
| 域名 + HTTPS 反代 | `127.0.0.1` | `1` |
| 场景 | `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 迁移历史数据(可选)
@@ -157,16 +166,28 @@ 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
ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs /opt/workbuddy-portal/backups
[Install]
WantedBy=multi-user.target
@@ -179,11 +200,15 @@ sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f
```
> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/` 归它所有:
> `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
> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data \
> /opt/workbuddy-portal/logs \
> /opt/workbuddy-portal/backups
> ```
> `backups/` 千万别漏 —— 漏了的表现是「备份管理」页能打开,但一备份就报错,
> 日志里是 `PermissionError`(服务以 `workbuddy` 身份跑,写不进 root 拥有的目录)。
### 3.2 Windows
@@ -266,20 +291,54 @@ docker compose logs -f
### 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` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 |
| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx |
| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空则随机生成并只在启动日志打印一次**(v1.4.0 起不再有 `admin123` 兜底) |
| `WB_THREADS` | `4`(compose)/ `8`(代码) | waitress 线程数 = 单实例能同时吃进几个慢请求(采集 / 导出 / 备份恢复) |
| `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) |
> `TZ`、`WB_COOKIE_SECURE`、`WB_DISABLE_SCHEDULER` 都是**进程环境变量**,
#### 容器资源上限(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 数据落点:命名卷,不是绑定挂载
@@ -288,6 +347,13 @@ docker compose logs -f
|---|---|---|
| `/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`**(这不是随手选的):
@@ -308,33 +374,41 @@ 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_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`
> 不一致:`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 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
```
---
@@ -363,10 +437,19 @@ name: workbuddy-portal
services:
portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
image: git.iwali.top/wangchuanli/workbuddy-portal:1.4.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:
@@ -378,10 +461,17 @@ services:
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
@@ -397,6 +487,7 @@ services:
volumes:
wb_data:
wb_logs:
wb_backups:
```
**`.env`**:
@@ -408,13 +499,22 @@ 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_IMPORT_CREDS=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 # 里面有密码
```
@@ -434,12 +534,30 @@ docker compose logs -f --tail=50
首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`):
1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建);
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,调度线程就在这个进程里。
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
@@ -473,7 +591,10 @@ docker compose exec portal python manage.py status
## 六、反向代理与 HTTPS
前面挂 nginx / Caddy / Traefik 时,有两点必须注意,否则会踩坑:
前面挂 nginx / Caddy / Traefik 时有三个要点,**第 3 条是 v1.4.0 新增的**,
漏了会以「登录限速误伤所有人」的形式出现:
**① nginx 侧透传真实 IP**
```nginx
server {
@@ -487,8 +608,7 @@ server {
proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上,
# 一个人被锁 → 全站被锁
# 透传真实 IP:登录失败限速、注册配额、验证码限速都按它计数
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
@@ -499,14 +619,47 @@ server {
}
```
| 现象 | 原因 |
**② 应用侧打开 `WB_TRUST_PROXY=1`(关键,别漏)**
程序**默认不信任** `X-Forwarded-For` —— 这是为了「直接暴露公网」时的安全:
XFF 的第一段是客户端自己填的,信了它,攻击者每次换一个伪造值就能绕过全部 IP 限速。
开启后程序取 XFF 里**最右侧**的合法 IP。最右侧是离你最近的那一跳(由你自己的代理写入),
客户端加不进去,所以下面两种 nginx 写法**都安全**:
| nginx 写法 | 效果 |
|---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
| `$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),
然后 `docker compose up -d` 重建容器。**只有真的能用 `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 可用 |
---
@@ -522,7 +675,7 @@ server {
```bash
export GITEA_TOKEN=<你的令牌>
tools/push-all.sh # 推 main 分支 + 镜像 latest
tools/push-all.sh 1.3.0 # 同时打一个版本 tag 并推送
tools/push-all.sh 1.4.0 # 同时打一个版本 tag 并推送
```
脚本做的事:
@@ -560,15 +713,15 @@ docker login git.iwali.top -u wangchuanli
docker compose build
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
```
### 7.4 验证远端
```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.4.0
# 或
curl -s -u wangchuanli:TOKEN \
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
@@ -589,17 +742,47 @@ curl -s -u wangchuanli:TOKEN \
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** |
| `exports/*.csv` | ★ | 导出快照,可再生 |
| `wb_logs` 卷 | ☆ | 排错用,可再生 |
| `wb_backups` 卷(`/app/backups`) | ★★★ | 备份归档 `usage-<时间戳>.zip`。**里面含一份 `instance.json`** —— 所以它的机密等级和密钥文件完全相同 |
`.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。
工程目录下的 `backups/` 就是给这类快照准备的位置(**刻意不放在 `data/`**:
`data/` 是 Docker 卷,`docker compose down -v` 会把备份和正本一起删掉)。
> **`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:整体打包命名卷(最完整,升级前做)**
**方式 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
@@ -612,30 +795,6 @@ docker run --rm \
docker compose start portal
```
**方式 B:只导数据库文件(最常用,每天做)**
```bash
# 先 checkpoint 把 WAL 落进主库,再拷 —— 这样单独一个 .sqlite 就是完整的
docker compose exec portal python -c "
from workbuddy_portal import db
db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')"
docker run --rm \
-v workbuddy-portal_wb_data:/data:ro \
-v "$PWD/backups":/backup \
alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite
```
> 也可以用 SQLite 官方的在线备份 API,**不用停服务、不用手工 checkpoint**:
> ```bash
> docker compose exec -T portal python -c "
> import sqlite3
> s = sqlite3.connect('/app/data/usage.sqlite'); d = sqlite3.connect('/tmp/b.sqlite')
> s.backup(d); d.close(); s.close()"
> docker compose cp portal:/tmp/b.sqlite ./backups/usage-$(date +%F).sqlite
> docker compose exec -T portal rm -f /tmp/b.sqlite
> ```
**方式 C:逻辑导出(跨版本最安全,可读性最好)**
```bash
@@ -643,11 +802,53 @@ docker compose exec portal python manage.py export-csv /app/data/exports
docker compose cp portal:/app/data/exports/. ./backups/exports/
```
建议:方式 B 每天跑(可用宿主机 cron)、方式 C 每季度跑一次、方式 A 在**升级前**跑。
裸机部署把上面命令里的 `docker compose exec portal` 去掉即可,路径换成相对的 `data/`。
建议:**方式 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
@@ -665,9 +866,6 @@ docker compose exec portal python manage.py stats # 核对条数与积分
> **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。
> 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。
>
> 只恢复了库、没恢复 `instance.json` 的话,所有账号的 Cookie 都会显示「无法解密」,
> 各自重新粘贴一次即可 —— 历史用量数据不受影响。
### 8.4 迁移:从绑定挂载换到命名卷
@@ -745,8 +943,9 @@ sudo systemctl restart workbuddy-portal
|---|---|---|---|
| **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` 表(备份索引)。**没有数据搬运、没有键位变动** | 不需要 |
两次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
三次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计:
```bash
docker compose logs portal | grep -iE "迁移|migrat|提升|promote"
@@ -764,10 +963,10 @@ docker compose exec portal python manage.py stats # 各账号条数与积分
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**) |
| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 |
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 |
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录;确认「备份管理」里今天/昨天有一份 `auto` 备份 |
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**);`docker stats --no-stream` 看一眼内存有没有贴着 512m 上限 |
| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);**把最新归档下载到容器之外**;跑一次备份恢复演练(见下) |
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用与 `wb_backups` 卷的体积 |
一键体检:
@@ -775,10 +974,30 @@ docker compose exec portal python manage.py stats # 各账号条数与积分
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 / 内存占用
```
磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3。
增长慢,但**年度归档后记得对旧数据做取舍**(本项目不做自动清理,数据只增不减)。
**备份恢复演练(每月一次,五分钟)**
不演练的备份等于没有备份 —— 只有在真的恢复过一次之后,你才知道那份 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` 自动清理)。
### 自检脚本(开发者向)
@@ -837,9 +1056,11 @@ docker compose up -d # 环境变量,必须 up -d,restart 不生效
|---|---|
| `/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`。
@@ -934,6 +1155,50 @@ docker compose exec portal date # 应该输出 CST / +0800
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`(实例级,仅管理员) |
---
## 十二、配置项速查
@@ -943,6 +1208,7 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。
**v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。
其余全部是**实例级**(`GLOBAL_KEYS`,v1.4.0 起共 **23** 个),只有管理员能改。
写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。
| 键 | 默认 | 作用域 | 谁能改 | 说明 |
@@ -959,6 +1225,12 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `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) |
@@ -994,13 +1266,19 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `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_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) |
| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程 |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**) |
| `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` | `./data` / `./logs` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) |
| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` / `WB_HOST_BACKUP_DIR` | `./data` / `./logs` / `./backups` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) |
### 12.3 `manage.py` 子命令速查
@@ -1019,5 +1297,8 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
| `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`。
+102 -9
查看文件
@@ -139,7 +139,8 @@
| 查看概览、用量大屏 | ✅(只有你自己的数据) |
| 查看 / 搜索数据明细、展开 Prompt | ✅(只有你自己的记录) |
| 导出 CSV(当前筛选条件) | ✅(只有你自己的记录) |
| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的) |
| **导出我的全部数据**(zip:记录 / 采集历史 / 审计 / 账号与配置) | ✅ **不含 Cookie 明文**,见 [10](#十个人中心) |
| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的,且有频率与跨度限制,见 [8.2](#82-手动采集你可以用)) |
| 「补全 Prompt」「导出我的 CSV」 | ✅(只动你自己的) |
| 查看采集运行历史 | ✅(只有你自己的) |
| **设置定时任务频率 / 开关 / 补跑策略** | ❌ **只读** |
@@ -147,6 +148,7 @@
| **查看日志管理页 / 应用日志** | ❌ 403 |
| **改实例级设置**(接口地址、开放注册、验证码策略) | ❌ |
| **用户管理**(建号 / 停用 / 删号 / 改权限) | ❌ 403 |
| **备份管理**:下载数据库归档、从备份恢复 | ❌ 403(恢复等于对全库数据有完整读写权,只给管理员) |
| **整理数据库**(`VACUUM`,整库操作) | ❌ |
> **为什么调度不给你改**:采集策略是**整机一套**的(一台部署一个调度时刻表,
@@ -160,9 +162,9 @@
| 是「你的」 | 是「共用的 / 管理员管」 |
|---|---|
| Cookie 与 User-Agent | 接口基址与路径 |
| 用量记录、采集历史、导出的 CSV | 采集调度时刻表与全部采集参数 |
| 用量记录、采集历史、导出的 CSV / zip | 采集调度时刻表与全部采集参数 |
| 个人资料、登录密码 | 是否开放自助注册、注册限额、验证码策略 |
| — | 数据库文件本身、应用日志 |
| — | 数据库文件本身、应用日志、**数据库备份归档** |
三条值得记牢:
@@ -172,6 +174,9 @@
**记录条数 / 积分合计 / 最后登录时间与 IP** —— 只给「有多少」,不给「是什么」。
- **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号),
所以每个账号都必须各自配一次 Cookie。
- **但备份是管理员的能力**:管理员能下载整个数据库的归档、也能用归档覆盖回来。
这是**运维必需**(不然数据丢了没人能救),代价是管理员对全库数据有完整的读写权。
介意这一点的话,就自己用「导出我的全部数据」留一份,见 [10](#十个人中心)。
---
@@ -371,9 +376,17 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
重扫**不会产生重复** —— 主键去重,已存在的记录按「更早的本地时间」保留。
> 这两个动作**只采集你自己的**数据,不会碰到别人的。
>
> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此
> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。
**三条限制**(v1.4.0 起,页面上会写出来):
| 限制 | 表现 | 为什么 |
|---|---|---|
| **有任务在跑时不能再发起** | 点「立即采集」返回「**正在采集**,请等它结束」 | SQLite 是单写者,并发采集只会互相拖慢,还可能把云端接口打得太密 |
| **两次手动采集之间有最小间隔**(默认 60 秒) | 返回「操作太频繁,请 N 秒后再试」 | 防止反复点按钮把请求刷爆 |
| **单次最长跨度 31 天**(约 1 个月) | 起始日期填得太早会被自动收窄,运行日志里出现一行 `[warn] 请求跨度超过上限 N 天,已自动收窄起点` | 避免一次拉取过长周期的数据(云端要分很多页,慢且容易失败) |
> 想补更久以前的数据?**分几次做**:比如先补 1 月,再补 2 月。
> 跨度过长的请求不是被拒就是跑到一半超时,分段的成功率反而更高。
### 8.3 运行历史
@@ -459,14 +472,37 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
|---|---|
| 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 |
| 修改资料 | 改显示名、邮箱(**用户名只读**) |
| 修改登录密码 | 需要原密码;改完当前会话仍然有效 |
| 修改登录密码 | 需要原密码。**改成功后其他设备上的登录会立刻失效**(本机这次会话保留,不然改完立刻被自己踢下线) |
| 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻(只读) |
| **导出我的全部数据** | 下载一个 zip,把**属于你的**东西一次带走(见下) |
顶栏右侧会有一个 **「普通账号」** 小标签(管理员则是「管理员」)——
不确定自己是什么权限时看一眼这里。
> 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。
#### 导出我的全部数据
页头有一个「**导出我的全部数据**」按钮(也有单独一张卡片说明它在打包什么)。
点下去会下载一个 zip,里面是:
| 文件 | 内容 |
|---|---|
| `使用记录.csv` | 你**全部**的用量明细(不受页面筛选条件影响) |
| `采集历史.csv` | 你的采集运行记录 |
| `操作审计.csv` | **你自己**的操作审计(别人的看不到) |
| `我的账号与配置.json` | 用户名 / 显示名 / 邮箱 / 角色 / 状态 / 本人的采集参数(只读项也在内) |
| `说明.txt` | 各文件的口径说明 |
> **里面没有 Cookie 明文。** 导出自己的数据不等于把凭证交出去 —— 凭证是密文入库的,
> 导出接口不碰它。需要迁移凭证就在新环境重新粘一次。
>
> 这个动作有 **10 秒的最小间隔**(防止连点生成一堆大文件)。数据量很大时
> 服务端是**边生成边下发**的,浏览器会先等一下再开始下载。
> 想留在系统里、由运维统一保管的那种备份(含所有人的数据),那是「备份管理」页的事,
> 只有管理员能做。你的 zip 只包含你自己。
---
## 十一、管理员专属功能
@@ -479,14 +515,20 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
| 项 | 默认 | 说明 |
|---|---|---|
| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑 |
| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑(**注意:自动备份也一起停**,见 [11.5](#115-备份管理页)) |
| 每日时刻 | `09:00,17:00` | 逗号分隔的本地时刻。**保存即生效,不用重启** |
| 每日时刻上限 | 6 | 最多允许几个时刻(上限 12,代码硬顶)。时刻数直接决定采集频次 |
| 启动补跑 | 开 | 启动时把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 12 小时 | 超过多少小时就不补了 |
| 采集最小间隔 | 60 秒 | 同一账号两次**手动**采集之间的最小间隔 |
| 单次最长跨度 | 31 天 | 一次采集最多覆盖多少天(硬顶 31 = 1 个月,改大也没用) |
| 采集参数 | 见 9.2 | 分页 / 回退 / 超时 / 截断 / 证书校验等 |
> ⚠️ **改 `schedule_times` 会清理槽位簿记**:系统只清掉「不再存在的时刻」对应的
> 槽位标记(所有账号一起清),**不做全清** —— 全清会让全部账号在宽限期内一起重采。
>
> ⚠️ **把「每日时刻上限」调小**时,超出上限的那几个时刻会被一并清掉(这是有意的:
> 上限本身就是刹车,留着超限的配置只会让人以为它还生效)。
### 11.2 实例级设置
@@ -506,6 +548,9 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
> 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删 ——
> 所以它不会随会话 Cookie 泄漏出去。
> **自动备份的三个设置**(开关 / 周期 / 保留份数)不在这里,它们有自己的页面,
> 见 [11.5 备份管理页](#115-备份管理页)。
### 11.3 日志管理页
![日志管理页](images/05-logs.png)
@@ -535,7 +580,7 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
| 改显示名 | 行内直接改,点该行「保存」生效 |
| 改权限 | 管理员 ↔ 普通 |
| 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) |
| 改密码 | 给忘了密码的同事重置 |
| 改密码 | 给忘了密码的同事重置。**重置后他在所有设备上的登录立刻失效**,需重新登录 |
| 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 |
列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**:
@@ -553,6 +598,54 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名
> **给同事开账号时默认选「普通」**:管理员是能停用别人账号的角色,没必要扩大。
### 11.5 备份管理页
![备份管理页](images/11-backups.png)
> 这一页**只有管理员能看到**(普通账号导航里没有,直接敲 `/backups` 返回 403)。
> 理由很实际:**能下载或恢复整个数据库的人,等于对全库数据有完整的读写权。**
**顶部四张 KPI**:现有份数 / 占用空间 / 自动备份开关 / 上次与下次自动备份时间。
**自动备份设置**(实例级,改完即生效):
| 设置 | 默认 | 说明 |
|---|---|---|
| 开启自动备份 | 开 | 关掉后调度线程不再打新快照(已有的不会删) |
| 备份周期 | 24 小时 | 1 ~ 720 小时。到期就打一份,**有采集在跑时跳过**,等下轮 |
| 保留份数 | 7 | 超出的**按时间删最旧的**。只按磁盘上**真实存在**的文件算份数 |
**备份列表**:每行给出文件名、大小、记录条数、积分、**来源**(自动 / 手动 / 命令行 /
恢复前的自动快照)、生成时间,以及三个按钮:
| 按钮 | 做什么 |
|---|---|
| 下载 | 把 zip 存到本地。**归档里含 `instance.json`,也就是全库的加密主密钥**,所以它本身就是最高机密 —— 别放进公开网盘、别随手发人 |
| 恢复 | 把这份归档灌回数据库。**会先弹两次确认**(第二次专门问「是否连密钥一起回滚」) |
| 删除 | 删掉这一份(文件 + 索引行) |
#### 「恢复」究竟做了什么(要点)
1. **先校验**这份归档能不能用;验不过就**一个字节都不动**。
2. **自动给当前库打一份快照**(来源标 `pre-restore`)—— 恢复错了还能回去。
3. **整表替换**记录 / 配置 / 账号等数据(在一个事务里,中途失败自动回滚)。
4. 可选把归档里的密钥文件一起覆盖回来。
5. **让所有人的登录立即失效** —— 恢复是全局性事件,旧会话描述的账号与权限
可能已经整个被换掉了。
> **几个常见疑问**
>
> - **归档是怎么来的?** 不是 `cp` 出来的。走的是 SQLite 官方在线备份 API,
> 按页复制并持有读事务 —— **采集正在写的时候拿到的也是一个完整一致的库**。
> 直接拷文件可能缺最近一段数据,而且**不报错**。
> - **为什么归档里要带密钥?** 不带的话,恢复出来的库里所有账号的 Cookie 都会
> 变成「无法解密」,等于把大家的凭证弄丢。所以默认带上;不想带就在恢复时选「否」。
> - **备份留在同一台机器上算备份吗?** 只算一半 —— 它防的是「改错了」,
> 防不了「机器没了」。**请定期点「下载」把归档拿到别的地方去**,
> 异地那一份是这套机制替不了你的部分。
> - **磁盘占用怎么办?** 「保留份数」会自动清理。也可以手动点删除,
> 或跑 `python manage.py backups --prune --keep 5`。
---
## 十二、信息安全与隐私安全
二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 689 KiB