将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。 新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0, 消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检, smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收, 忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
42 KiB
部署与运维指南
面向部署者 / 运维。三条并列的部署路径,选一条走到底;之后是备份、升级、排错。
目录
- 一、三条部署路径怎么选
- 二、部署前准备(三条路径共用)
- 三、路径 A:裸机部署
- 四、路径 B:Docker 自打包部署
- 五、路径 C:docker-compose 拉云端镜像部署
- 六、反向代理与 HTTPS
- 七、把代码与镜像推到 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) |
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。
# 默认是 admin / admin123 —— 局域网部署下必须改掉!
python manage.py init --user admin --password '你的强密码'
② 访问方式。 决定 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 |
2.4 从 v1.0 迁移历史数据(可选)
如果你有旧版单文件脚本攒下的 CSV(usage_records.csv),新版本能直接导入:
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
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
[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
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
Restart=always
RestartSec=5
# ---- 加固(可选但建议)----
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now workbuddy-portal
sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f
User=workbuddy需要事先建号,并让data/、logs/归它所有: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
3.2 Windows
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 步骤
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_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_IMAGE |
git.iwali.top/…:latest |
镜像名(见 第七节) |
TZ、WB_COOKIE_SECURE、WB_DISABLE_SCHEDULER都是进程环境变量,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) |
为什么是命名卷而不是脚本目录里的 ./data(这不是随手选的):
Windows + Docker Desktop 的绑定挂载走 9p(
aname=drvfs;path=C:\)。 宿主的 Windows 进程只要访问过这个 WAL 库 —— 哪怕只是manage.py stats这种纯读 —— 容器侧下一次打开就会sqlite3.OperationalError: unable to open database file, 而且不会自愈,必须重启容器。复现步骤见 11.5。
命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题, 所以容器独占数据目录是唯一在所有平台上都正确的做法。
代价:宿主机的 manage.py 不能直接读容器里的库。替代做法都是一行命令:
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:
docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d
# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录
⚠️ 只建议 Linux 宿主机使用(bind mount 与容器同一文件系统,WAL 语义正常)。 Windows + Docker Desktop 下必然踩上面那个坑。
Linux 首次运行若报
unable to open database file,是宿主目录属主与容器内 uid 1000 不一致:sudo chown -R 1000:1000 ./data ./logs
4.6 常用命令
docker compose ps
docker compose logs -f --tail=100
docker compose restart # 注意:环境变量改动 restart 不生效
docker compose down # 停并删容器,数据保留在卷里
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 # 调度与最近采集
五、路径 C:docker-compose 拉云端镜像部署
适合:NAS、服务器、不想 clone 仓库、只想尽快跑起来。镜像由 第七节 推送到 Gitea 注册表,这里只负责拉下来运行。
5.1 前置
- Docker + Compose v2
- 能访问
git.iwali.top(有 Let's Encrypt 通配证书,HTTPS 直接可用, 不需要配insecure-registries)
5.2 建目录、写两个文件
mkdir -p /opt/workbuddy-portal && cd /opt/workbuddy-portal
docker-compose.yml(完整可用,不依赖仓库里的其它文件):
name: workbuddy-portal
services:
portal:
image: git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
container_name: workbuddy-portal
restart: unless-stopped
init: true # tini 接管 PID 1,docker stop 能干净传到 python
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}
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
volumes:
- wb_data:/app/data # 数据正本 + instance.json + exports
- wb_logs:/app/logs
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:
.env:
cp /dev/null .env
cat >> .env <<'EOF'
WB_BIND=0.0.0.0
WB_PORT=8848
TZ=Asia/Shanghai
# 首个管理员(只在数据库为空时生效)—— 务必改掉默认值
WB_ADMIN_USER=admin
WB_ADMIN_PASSWORD=换成你的强密码
WB_DISABLE_SCHEDULER=0
WB_COOKIE_SECURE=0
WB_IMPORT_CREDS=0
EOF
chmod 600 .env # 里面有密码
5.3 登录注册表并启动
# 如果镜像是公开仓库,可以跳过 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):
manage.py init—— 建表 + 灌默认配置 + 创建首个管理员(幂等,库非空时不重建);- 可选导入 ——
WB_IMPORT_CREDS=1/WB_IMPORT_XLSX=…才触发; exec manage.py serve—— 交接给 waitress,调度线程就在这个进程里。
日志里看到 [entrypoint] 启动 Web 服务(waitress)… 就是起来了。
5.4 完成后的检查清单
# 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 升级(本路径最省事)
# 改 docker-compose.yml 里的 tag,或
docker compose pull # 拉 latest
docker compose up -d # 重建容器;数据在命名卷里不受影响
docker compose exec portal python manage.py status
六、反向代理与 HTTPS
前面挂 nginx / Caddy / Traefik 时,有两点必须注意,否则会踩坑:
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 太短 |
配好 HTTPS 后,把 .env 里的 WB_COOKIE_SECURE 改成 1(会话 Cookie 只走 HTTPS),
然后 docker compose up -d 重建容器。只有真的能用 https:// 访问时才这么做。
七、把代码与镜像推到 Gitea 注册表
路径 C 需要的镜像就是在这里产出的。代码仓库与镜像的目标都是
git.iwali.top/wangchuanli/workbuddy-portal。
7.1 一条命令推代码 + 推镜像
先在 Gitea「设置 → 应用 → 生成令牌」创建 Access Token,勾选 repo + write:package:
export GITEA_TOKEN=<你的令牌>
tools/push-all.sh # 推 main 分支 + 镜像 latest
tools/push-all.sh 1.3.0 # 同时打一个版本 tag 并推送
脚本做的事:
git push origin main—— 用http.extraHeader传 Basic 认证,Token 只在环境变量里, 不会写进.git/config、URL 或 reflog;同时用-c credential.helper=屏蔽凭据助手 (否则在非交互 / 无桌面会话里 Git Credential Manager 会挂住等弹窗)。docker compose build—— 镜像名本身就是注册表地址。docker login+docker push(--password-stdin,Token 不进命令行历史)。
不用脚本、手工推也行:
git push origin main弹出凭据窗口时,用户名填wangchuanli, 密码处填 Access Token(不是网页登录密码)。
7.2 传输协议:默认走 HTTPS
git.iwali.top 有 Let's Encrypt 通配证书(*.iwali.top):
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 手工构建与推送
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.3.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
7.4 验证远端
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.3.0
# 或
curl -s -u wangchuanli:TOKEN \
https://git.iwali.top/api/v1/packages/wangchuanli?type=container
八、备份与恢复
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 卷 |
☆ | 排错用,可再生 |
.env 不在里面 —— 它含密码,单独用密码管理器保管。
工程目录下的 backups/ 就是给这类快照准备的位置(刻意不放在 data/:
data/ 是 Docker 卷,docker compose down -v 会把备份和正本一起删掉)。
8.2 备份
方式 A:整体打包命名卷(最完整,升级前做)
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
方式 B:只导数据库文件(最常用,每天做)
# 先 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:
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:逻辑导出(跨版本最安全,可读性最好)
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/。
8.3 恢复
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)最省心。只恢复了库、没恢复
instance.json的话,所有账号的 Cookie 都会显示「无法解密」, 各自重新粘贴一次即可 —— 历史用量数据不受影响。
8.4 迁移:从绑定挂载换到命名卷
如果之前用了 docker-compose.hostdir.yml(或旧版把 ./data 直接挂进去):
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 不再被容器使用,留作迁移前的冷备即可。
九、升级与回滚
升级前
- 先备份(见 第八节)。备份要同时包含
usage.sqlite与instance.json—— 后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。 - 看一眼 CHANGELOG 有没有破坏性变更(
主版本与标了 变更(不兼容) 的条目)。 - 记下当前版本:
docker compose exec portal python -c "import workbuddy_portal;print(workbuddy_portal.__version__)"
Docker
git pull # 路径 C 跳过这步
docker compose build # 路径 C 改用 docker compose pull
docker compose up -d # 重建容器;数据在命名卷里不受影响
docker compose exec portal python manage.py status
裸机
git pull
.venv/bin/pip install -r requirements.txt
sudo systemctl restart workbuddy-portal
回滚
# 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。回滚到更老的代码时, 服务会认为库"版本过高"而拒绝启动(或反之重跑迁移)。回滚代码的同时要把库一起回滚, 用升级前那份快照。想确认当前库结构版本:
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 |
不需要 |
两次迁移都是幂等的(可重复启动、可重复执行),各自会写一条审计:
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不匹配,各自重新粘贴一次即可。
十、日常巡检
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
| 每周 | 「日志管理」按 warn / error 筛一遍,看有没有 TLS 或解密类告警(仅管理员) |
| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 |
| 每季度 | manage.py vacuum;出一份全量 CSV 归档;检查磁盘占用 |
一键体检:
docker compose exec portal python manage.py status # 调度与最近采集
docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细
docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态
磁盘长这样:数据库每 1000 条记录约 1.5 MB,app.log 滚动上限 2 MB × 3。
增长慢,但年度归档后记得对旧数据做取舍(本项目不做自动清理,数据只增不减)。
自检脚本(开发者向)
仓库自带三层验证,改完代码或升级后可以跑:
.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 容器起来了但页面打不开
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 下回传;服务端每次都收不到会话,
就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」。
# .env
WB_COOKIE_SECURE=0
docker compose up -d # 环境变量,必须 up -d,restart 不生效
11.3 普通账号看不到「日志管理」/「用户管理」
这是设计如此,不是 bug。 见 用户指南 · 权限与数据边界。
| 入口 | 普通账号 |
|---|---|
/logs、/logs/tail |
403(导航里不显示) |
/users、/api/users |
403(导航里不显示) |
| 任务管理页的调度表单 | 只读(时刻由管理员统一设定) |
| 配置管理页的采集参数 | 只读 |
| 本人的 Cookie / User-Agent | 可读写(唯一可改的配置) |
改权限: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;
若手工传过文件:
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 实测):
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
关键点:纯读也会触发,而且宿主进程退出后容器不会自愈 —— 只有重启容器才恢复。
处理:
- 根治(推荐):不要用
hostdir叠加层,直接用默认的命名卷。 容器独占/app/data,宿主侧一律通过docker compose exec portal python manage.py …操作。 - 临时:
docker compose restart portal立刻恢复,但下一次宿主访问会再坏一次。 - 想在宿主侧看数据:用 8.2 的方式导出到宿主目录再看,不要直接连库。
Linux 宿主机上不存在这个问题(bind mount 与容器是同一文件系统), 所以
docker-compose.hostdir.yml对 Linux 是安全的。
11.6 时区不对导致日期错位
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;根治办法是改用命名卷 |
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 想临时关掉自动采集
「任务管理 → 取消勾选『启用调度』→ 保存」(仅管理员)。或者临时改环境变量:
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
十二、配置项速查
12.1 数据库里的配置(改完立即生效,不用重启)
表主键是 (user_id, key):user_id=0 表示实例级(所有账号共用),其余是个人级。
v1.3.0 起只有两个键是个人级的(cookie / user_agent)—— 普通账号唯一能改的东西。
写权限判断统一走 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 小时) |
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_COOKIE_SECURE |
0 |
1 = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 0) |
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) |
12.3 manage.py 子命令速查
| 命令 | 作用 |
|---|---|
init [--user U] [--password P] |
初始化 / 迁移数据库,创建首个管理员(幂等) |
serve [--host H] [--port P] [--debug] [--no-scheduler] |
启动 Web(含进程内调度) |
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) |
Docker 部署下前面加 docker compose exec portal。