数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
687 行
25 KiB
Markdown
687 行
25 KiB
Markdown
# 部署与运维指南
|
||
|
||
> 面向**运维 / 部署者**。从零到跑起来,以及跑起来之后的备份、升级、排错。
|
||
|
||
**目录**
|
||
|
||
- [一、部署方式怎么选](#一部署方式怎么选)
|
||
- [二、Docker Compose 部署](#二docker-compose-部署)
|
||
- [三、裸机部署](#三裸机部署)
|
||
- [四、反向代理与 HTTPS](#四反向代理与-https)
|
||
- [五、把镜像推到 Gitea 注册表](#五把镜像推到-gitea-注册表)
|
||
- [六、备份与恢复](#六备份与恢复)
|
||
- [七、升级与回滚](#七升级与回滚)
|
||
- [八、日常巡检](#八日常巡检)
|
||
- [九、排错](#九排错)
|
||
- [十、配置项速查](#十配置项速查)
|
||
|
||
---
|
||
|
||
## 一、部署方式怎么选
|
||
|
||
| 场景 | 建议 |
|
||
|---|---|
|
||
| 有 Docker(NAS / 服务器 / 本机 Docker Desktop) | **Docker Compose**,最省事,升级只需换镜像 |
|
||
| 不想装 Docker,或要跑在 Windows 上用系统计划任务兜底 | 裸机 Python + `waitress` |
|
||
| 想给多人访问 | 任一种方式 + 反向代理(加 HTTPS 更稳) |
|
||
|
||
> **不要横向扩展**。SQLite 是单写者,调度线程也在 Web 进程内,
|
||
> 多副本只会带来锁竞争和重复采集。这个服务天然是单实例的。
|
||
|
||
---
|
||
|
||
## 二、Docker Compose 部署
|
||
|
||
### 2.1 前置
|
||
|
||
- Docker Engine 20.10+ / Docker Desktop(含 Compose v2)
|
||
- 至少 200 MB 磁盘(镜像 152 MB + 数据)
|
||
|
||
### 2.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`。
|
||
|
||
### 2.3 `.env` 主要变量
|
||
|
||
| 变量 | 默认 | 说明 |
|
||
|---|---|---|
|
||
| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` |
|
||
| `WB_PORT` | `8848` | 宿主机端口 |
|
||
| `TZ` | `Asia/Shanghai` | **影响「每日 09:00/17:00」与所有日期口径** |
|
||
| `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 |
|
||
|
||
> `TZ` 与 `WB_COOKIE_SECURE` 都是**进程环境变量**,`docker compose restart` 不生效,要 `up -d`。
|
||
|
||
### 2.4 数据落点:用命名卷,不用绑定挂载
|
||
|
||
| 容器内 | 存放位置 | 内容 |
|
||
|---|---|---|
|
||
| `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(`secret_key` + `cookie_key`)、`exports/` |
|
||
| `/app/logs` | Docker 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) |
|
||
|
||
**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的):
|
||
|
||
> Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。
|
||
> 宿主的 Windows 进程只要访问过这个 WAL 库——**哪怕只是 `manage.py stats` 这种纯读**——
|
||
> 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`,
|
||
> 而且**不会自愈**,必须 `docker compose restart portal`。实测复现见
|
||
> [第九节「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库)。
|
||
|
||
命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题,
|
||
所以容器独占数据目录是**唯一在所有平台上都正确**的做法。
|
||
|
||
**代价**:宿主机的 `manage.py` 不能直接读容器里的库了。随之而来的三件事都有一行命令替代:
|
||
|
||
```bash
|
||
# 在容器里跑 CLI(推荐,始终打到同一份数据)
|
||
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 指定目标目录
|
||
```
|
||
|
||
> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器是同一个文件系统,
|
||
> SQLite 的 WAL 与文件锁语义正常)。Windows + Docker Desktop 下会踩上面那个坑。
|
||
>
|
||
> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 不一致:
|
||
> ```bash
|
||
> sudo chown -R 1000:1000 ./data ./logs
|
||
> ```
|
||
|
||
### 2.5 常用命令
|
||
|
||
```bash
|
||
docker compose ps
|
||
docker compose logs -f --tail=100
|
||
docker compose restart
|
||
docker compose down # 停并删容器,数据保留
|
||
docker compose up -d --build # 改完代码重新构建
|
||
|
||
# 在容器里跑 CLI(同一个数据卷)
|
||
docker compose exec portal python manage.py stats
|
||
docker compose exec portal python manage.py status
|
||
docker compose exec portal python manage.py passwd admin 新密码
|
||
docker compose exec portal python manage.py vacuum
|
||
docker compose exec portal python manage.py collect # 手动采集一次
|
||
```
|
||
|
||
> **本机调试**(Docker Desktop on Windows)已实测:命名卷上的 SQLite(WAL)读写正常,
|
||
> 启动补采、采集、导出、CSV 流式下载、健康检查全部可用;
|
||
> 且**宿主侧再跑 `manage.py` / `tools/smoke.py` 都不会影响容器**(这正是改用命名卷的原因)。
|
||
|
||
---
|
||
|
||
## 三、裸机部署
|
||
|
||
### 3.1 Windows
|
||
|
||
```bat
|
||
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
|
||
cd workbuddy-portal
|
||
py -3 -m venv .venv
|
||
.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`,起始位置设为项目目录。
|
||
|
||
### 3.2 Linux
|
||
|
||
```bash
|
||
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
|
||
cd workbuddy-portal
|
||
python3 -m venv .venv
|
||
.venv/bin/pip install -r requirements.txt
|
||
.venv/bin/python manage.py init --user admin --password 你的强密码
|
||
```
|
||
|
||
`/etc/systemd/system/workbuddy-portal.service`:
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=WorkBuddy Portal
|
||
After=network-online.target
|
||
Wants=network-online.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=workbuddy
|
||
WorkingDirectory=/opt/workbuddy-portal
|
||
Environment=TZ=Asia/Shanghai
|
||
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
|
||
Restart=always
|
||
RestartSec=5
|
||
|
||
[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
|
||
```
|
||
|
||
> **不要**用 `manage.py collect` + cron 替代内置调度,除非你确实想让调度留在外部
|
||
> (那种情况下 Web 端要加 `--no-scheduler`,避免和 cron 抢锁——虽然文件锁会保证正确性,
|
||
> 但会白跑一次)。
|
||
|
||
---
|
||
|
||
## 四、反向代理与 HTTPS
|
||
|
||
前面挂 nginx 时要注意两点,否则会踩坑:
|
||
|
||
```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; # 采集/导出可能跑几分钟
|
||
}
|
||
}
|
||
```
|
||
|
||
排错要点:
|
||
|
||
| 现象 | 原因 |
|
||
|---|---|
|
||
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` |
|
||
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
|
||
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
|
||
|
||
---
|
||
|
||
## 五、把代码与镜像推到 Gitea
|
||
|
||
Gitea 自带容器注册表(`registry/2.0`)。代码仓库与镜像的目标都是 `git.iwali.top/wangchuanli/workbuddy-portal`。
|
||
|
||
### 5.0 一条命令推代码 + 推镜像
|
||
|
||
准备好 Gitea **Access Token**(「设置 → 应用 → 生成令牌」,勾选 `repo` + `write:package`)后:
|
||
|
||
```bash
|
||
export GITEA_TOKEN=<你的令牌>
|
||
tools/push-all.sh # 推 main 分支 + 镜像 latest
|
||
tools/push-all.sh 1.1.0 # 同时打一个版本 tag 并推送
|
||
```
|
||
|
||
脚本做的事:
|
||
|
||
1. `git push origin main` —— 用 `http.extraHeader` 传 Basic 认证,Token **只在环境变量里**,
|
||
不会写进 `.git/config`、URL 或 reflog;同时用 `-c credential.helper=` 屏蔽凭据助手
|
||
(否则在非交互 / 无桌面会话里 Git Credential Manager 会挂住等弹窗)。
|
||
2. `docker compose build` —— 镜像名本身就是注册表地址。
|
||
3. `docker login` + `docker push`(`--password-stdin`,Token 不进命令行历史)。
|
||
|
||
> 不用脚本、手工推也行:`git push origin main` 弹出凭据窗口时,
|
||
> 用户名填 `wangchuanli`,**密码处填 Access Token**(不是网页登录密码)。
|
||
|
||
### 5.1 传输协议:默认走 HTTPS(不用配 insecure-registries)
|
||
|
||
`git.iwali.top` 有 **Let's Encrypt 通配证书(`*.iwali.top`)**,HTTPS 全程可用:
|
||
|
||
```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://`)时,才需要在 Docker Desktop
|
||
> **Settings → Docker Engine** 的 `daemon.json` 里加:
|
||
> ```json
|
||
> { "insecure-registries": ["git.iwali.top"] }
|
||
> ```
|
||
> 然后 `Apply & Restart`(Linux 改 `/etc/docker/daemon.json` 后重启 docker)。
|
||
> 明文传输会让 Token 暴露在网络里,**能走 HTTPS 就不要开这个口子**。
|
||
|
||
### 5.2 登录
|
||
|
||
```bash
|
||
docker login git.iwali.top -u wangchuanli
|
||
# 密码用 Personal Access Token(Gitea「设置 → 应用 → 生成令牌」,
|
||
# 勾选 write:package;不要用网页登录密码)
|
||
```
|
||
|
||
### 5.3 构建并推送
|
||
|
||
```bash
|
||
# 镜像名默认已经是注册表地址(见 docker-compose.yml 的 image: 字段)
|
||
docker compose build
|
||
|
||
# 打上语义化版本标签
|
||
docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \
|
||
git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
||
|
||
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
|
||
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
||
```
|
||
|
||
### 5.4 在另一台机器上拉取运行
|
||
|
||
```bash
|
||
docker pull git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
||
|
||
# 不 clone 仓库也能跑:只写一个 compose 文件
|
||
cat > docker-compose.yml <<'YAML'
|
||
services:
|
||
portal:
|
||
image: git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
||
restart: unless-stopped
|
||
ports: ["8848:8848"]
|
||
environment:
|
||
TZ: Asia/Shanghai
|
||
WB_ADMIN_PASSWORD: "改成你的强密码"
|
||
volumes:
|
||
- wb_data:/app/data
|
||
- wb_logs:/app/logs
|
||
|
||
volumes:
|
||
wb_data:
|
||
wb_logs:
|
||
YAML
|
||
|
||
docker compose up -d
|
||
```
|
||
|
||
### 5.5 验证远端
|
||
|
||
```bash
|
||
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
|
||
# 或
|
||
curl -s -u wangchuanli:TOKEN \
|
||
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
|
||
```
|
||
|
||
---
|
||
|
||
## 六、备份与恢复
|
||
|
||
### 6.1 备份什么
|
||
|
||
数据都在命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`):
|
||
|
||
| 文件 | 重要性 | 说明 |
|
||
|---|---|---|
|
||
| `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
|
||
| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
|
||
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了/被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需要各自重填** |
|
||
| `exports/*.csv` | ★ | 导出快照,可再生 |
|
||
| `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 |
|
||
|
||
`.env` 不在里面——它含密码,**单独用密码管理器保管**。
|
||
|
||
> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 2.4 与第九节)。
|
||
|
||
### 6.2 备份
|
||
|
||
**方式 A:整体打包命名卷(推荐,最完整)**
|
||
|
||
```bash
|
||
docker compose stop portal
|
||
|
||
docker run --rm \
|
||
-v workbuddy-portal_wb_data:/data:ro \
|
||
-v "$PWD/backup":/backup \
|
||
alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data .
|
||
|
||
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/backup":/backup \
|
||
alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite
|
||
```
|
||
|
||
**方式 C:逻辑导出(跨版本最安全,可读性最好)**
|
||
|
||
```bash
|
||
docker compose exec portal python manage.py export-csv /app/data/exports
|
||
docker compose cp portal:/app/data/exports/. ./backup/exports/
|
||
```
|
||
|
||
建议方式 B 每天跑、方式 C 每季度跑一次;方式 A 在升级前跑。
|
||
|
||
### 6.3 恢复
|
||
|
||
```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/backup":/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 里记的是相对旧库的增量,配错会损坏数据。
|
||
> 用方式 B 的备份(已 checkpoint)最省心,恢复时目标目录里只放一个 `.sqlite` 即可。
|
||
|
||
### 6.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` 就不再被容器使用了,可以留作迁移前的冷备。
|
||
|
||
---
|
||
|
||
## 七、升级与回滚
|
||
|
||
### Docker
|
||
|
||
```bash
|
||
git pull
|
||
docker compose build
|
||
docker compose up -d # 重建容器,数据在挂载卷里不受影响
|
||
docker compose exec portal python manage.py stats
|
||
```
|
||
|
||
回滚:把 `.env` 里的 `WB_IMAGE` 指回旧版本标签,然后
|
||
|
||
```bash
|
||
docker compose up -d --no-build
|
||
```
|
||
|
||
### 裸机
|
||
|
||
```bash
|
||
git pull
|
||
.venv/bin/pip install -r requirements.txt
|
||
sudo systemctl restart workbuddy-portal
|
||
```
|
||
|
||
### 升级前
|
||
|
||
1. **先备份**(见第六节)。备份要**同时包含 `usage.sqlite` 与 `instance.json`** ——
|
||
后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。
|
||
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。
|
||
|
||
### 1.1.0 → 1.2.0(单用户 → 多用户)
|
||
|
||
**无需任何手工迁移命令。** 首次用新版启动时会自动完成,日志里能看到:
|
||
|
||
| 做了什么 | 效果 |
|
||
|---|---|
|
||
| 建 `users` 表、写入首个管理员 | 用 `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD`,或沿用 `admin` / `admin123` |
|
||
| `settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键 | 老数据整体归到**第一个账号** |
|
||
| 明文 Cookie 就地加密 | 日志记一条 `明文凭证已加密:settings[uid=1].cookie` |
|
||
| 建 `captchas` 表、补索引 | 验证码用 |
|
||
|
||
迁移由 `PRAGMA user_version` 驱动,**幂等**:重复启动不会重复执行。
|
||
校验一下:
|
||
|
||
```bash
|
||
docker compose logs portal | grep -i "迁移\|migrat"
|
||
docker compose exec portal python manage.py users # 账号 / 角色 / 数据量 / 凭证状态
|
||
docker compose exec portal python manage.py stats # 各账号条数与积分
|
||
```
|
||
|
||
> 升级后请**确认 Cookie 能解密**:登录后打开「配置管理 → 我的云端凭证」,
|
||
> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「无法解密」,说明 `instance.json`
|
||
> 不匹配,重新粘贴一次即可。
|
||
|
||
---
|
||
|
||
## 八、日常巡检
|
||
|
||
| 频率 | 做什么 |
|
||
|---|---|
|
||
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
|
||
| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警 |
|
||
| 每月 | 确认 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 |
|
||
| 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 |
|
||
|
||
一键体检:
|
||
|
||
```bash
|
||
docker compose exec portal python manage.py status
|
||
docker compose exec portal python manage.py stats
|
||
```
|
||
|
||
---
|
||
|
||
## 九、排错
|
||
|
||
### 容器起来了但页面打不开
|
||
|
||
```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` 了;防火墙有没有放行 |
|
||
|
||
### 登录成功却立刻又跳回登录页
|
||
|
||
几乎一定是 `WB_COOKIE_SECURE` 被设成了 `1`,而你在用 **HTTP** 访问。
|
||
|
||
会话 Cookie 带 `Secure` 属性后,浏览器只在 HTTPS 下才回传;服务端每次都收不到会话,
|
||
就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」,日志里
|
||
看起来像在反复登录。
|
||
|
||
```bash
|
||
# .env
|
||
WB_COOKIE_SECURE=0
|
||
docker compose up -d # 环境变量,必须 up -d,restart 不生效
|
||
```
|
||
|
||
只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
|
||
(另:如果站点前后端域名不同,还要看第四节的反代配置。)
|
||
|
||
### `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'))"
|
||
```
|
||
|
||
### 数据库相关
|
||
|
||
| 报错 | 原因 / 处理 |
|
||
|---|---|
|
||
| `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` |
|
||
| `unable to open database file`(**Windows + Docker Desktop**) | 见下面 [「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库);根治办法是用默认的命名卷 |
|
||
| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) |
|
||
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 |
|
||
|
||
### 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
|
||
```
|
||
|
||
关键点:**纯读也会触发**,而且**宿主进程退出后容器不会自愈**——
|
||
只有 `docker compose restart portal` 才恢复。
|
||
|
||
**处理**:
|
||
|
||
1. **根治(推荐)**:不要用 `hostdir` 叠加层,直接用默认的**命名卷**
|
||
(`docker-compose.yml`)。容器独占 `/app/data`,宿主侧一律通过
|
||
`docker compose exec portal python manage.py …` 操作。
|
||
2. **临时**:`docker compose restart portal` 立刻恢复,但下一次宿主访问会再坏一次。
|
||
3. **想在宿主侧看数据**:用第六节的方式导出到宿主目录再看,不要直接连库。
|
||
|
||
> Linux 宿主机上不存在这个问题(bind mount 与容器是同一个文件系统),
|
||
> 所以 `docker-compose.hostdir.yml` 对 Linux 是安全的。
|
||
|
||
### 采集相关
|
||
|
||
| 报错 | 处理 |
|
||
|---|---|
|
||
| `cookie_expired` / `401` | 重新获取 Cookie 填进「配置管理」,然后按区间补采 |
|
||
| `TLS` / `SSLError` | 企业代理 / 自签证书场景,临时把 `ssl_verify` 设为 `0`;否则保持开启 |
|
||
| 返回 `409 busy` | 正常——已有采集在跑,等它结束 |
|
||
| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 |
|
||
|
||
### 时区不对导致日期错位
|
||
|
||
```bash
|
||
docker compose exec portal date # 应该输出 CST / +0800
|
||
```
|
||
|
||
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,改完 `docker compose up -d` 重建容器
|
||
(`TZ` 是环境变量,`restart` 不生效)。
|
||
|
||
### 想临时关掉自动采集
|
||
|
||
「任务管理 → 取消勾选『启用调度』→ 保存」。或者
|
||
|
||
```bash
|
||
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
|
||
```
|
||
|
||
---
|
||
|
||
## 十、配置项速查
|
||
|
||
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
|
||
也可以直接改表)。表的主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用,
|
||
仅管理员可改),其余是**个人级**(每个账号一份,互不可见)。
|
||
|
||
| 键 | 默认 | 作用域 | 说明 |
|
||
|---|---|---|---|
|
||
| `api_base` / `api_path` | 官方地址 | **实例级** | 接口地址(走镜像/代理时改) |
|
||
| `allow_register` | `1` | **实例级** | 是否开放自助注册 |
|
||
| `register_max_per_ip` | `3` | **实例级** | 同一 IP 每日注册上限(1~50) |
|
||
| `captcha_policy` | `always` | **实例级** | `always` / `adaptive` / `off` |
|
||
| `captcha_length` | `4` | **实例级** | 验证码位数(4~6) |
|
||
| `cookie` | 空 | 个人级 | 账号凭证,**密文入库**;页面只回「长度 + 结尾 4 位」 |
|
||
| `user_agent` | Chrome UA | 个人级 | 与 Cookie 同源更稳。**`cookie` 与 `user_agent` 不参与实例级回落**(回落 = 串号越权) |
|
||
| `schedule_enabled` | `1` | 个人级 | 调度总开关 |
|
||
| `schedule_times` | `09:00,17:00` | 个人级 | 每日时刻,逗号分隔,本地时区 |
|
||
| `catch_up` | `1` | 个人级 | 启动补跑开关 |
|
||
| `catch_up_grace_hours` | `12` | 个人级 | 补跑宽限期(小时) |
|
||
| `page_size` | `200` | 个人级 | 采集单页条数(20~1000) |
|
||
| `rewind_minutes` | `2` | 个人级 | 断点回退分钟数(0~120) |
|
||
| `drift_tolerance_minutes` | `5` | 个人级 | 云端时间漂移告警阈值(0~720) |
|
||
| `max_prompt` | `2048` | 个人级 | Prompt 入库截断长度,0 = 不截断 |
|
||
| `verify_days` | `0` | 个人级 | 采集后整日校验天数(0~90) |
|
||
| `timeout` | `30` | 个人级 | HTTP 超时秒数(5~300) |
|
||
| `ssl_verify` | `1` | 个人级 | 校验云端 HTTPS 证书 |
|
||
|
||
**读配置时有三级回落**:个人级 → 实例级 → 代码里的 `DEFAULTS`。
|
||
所以实例级的值只是「默认值」,任何账号都可以用自己的值覆盖它(`cookie` / `user_agent` 例外)。
|
||
|
||
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒——
|
||
接口不能用来往 `settings` 表里塞任意键。
|
||
|
||
环境变量(启动期,改了要重建容器):
|
||
|
||
| 变量 | 说明 |
|
||
|---|---|
|
||
| `TZ` | 时区,影响所有日期口径 |
|
||
| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 |
|
||
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 |
|
||
| `WB_COOKIE_SECURE` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) |
|
||
| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 |
|
||
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) |
|
||
| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 |
|