文件
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

70 KiB
原始文件 Blame 文件历史

部署与运维指南

面向部署者 / 运维。三条并列的部署路径,选一条走到底;之后是备份、升级、排错。

想了解界面怎么用,看 用户使用指南;想了解内部结构,看 架构说明。

目录


一、三条部署路径怎么选

路径 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。

python manage.py init --user admin --password '你的强密码'

不设密码会怎样(v1.4.0 起):程序用 secrets.token_urlsafe(12) 生成一个随机口令, 用醒目的方框打印在输出里,只在这一次打印。库里只有散列,日志一滚就再也拿不回来。 没有 admin123 这类默认口令了 —— 硬编码一个默认口令等于把公网实例的钥匙挂在门上。 所以要么现在就显式设 WB_ADMIN_PASSWORD,要么把打印出来的那行抄走。 容器里的对应做法见 11.10 拿不到管理员初始口令。

② 访问方式。 决定 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),新版本能直接导入:

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
# 备份落点默认就是 <仓库>/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
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/ 归它所有:

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

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_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 镜像名(见 第七节)

容器资源上限(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)

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。

命名卷住在 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 / 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 常用命令

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 注册表,这里只负责拉下来运行。

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.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:

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 登录注册表并启动

# 如果镜像是公开仓库,可以跳过 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 只给最近若干行;容器重启多轮后提示会被挤出去):

docker compose logs portal | grep -A6 '管理员初始口令'

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 时有三个要点,第 3 条是 v1.4.0 新增的, 漏了会以「登录限速误伤所有人」的形式出现:

① nginx 侧透传真实 IP

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 里最左边那一段。

# .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:

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):

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.5.0
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.5.0

7.4 验证远端

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 侧会静默地什么都没有:

# 远端有哪些版本 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)。

容器里归档落在独立的命名卷 workbuddy-portal_wb_backups(对应 /app/backups), 刻意不与 data/ 同卷:docker compose down -v 会把同卷的正本与副本一起删掉 —— 那正好是最需要备份的时刻。裸机部署下就是工程目录里的 backups/。

不要在容器运行时用宿主机的 manage.py 去碰库(见 4.5 与 11.5)。

8.2 备份

方式 A:用内置备份(推荐,v1.4.0 起)

三种触发方式,产出的是同一种归档:

方式 怎么做
自动 「备份管理」页开 backup_enabled,设周期(默认 24 小时)与保留份数(默认 7)。调度线程每 20 秒检查一次,有采集在跑就跳过(不跟采集抢磁盘),到期自动打一份并按份数清理最旧的
页面 「备份管理 → 立即备份」,列表里可下载(拿 zip)、恢复、删除
命令行 docker compose exec portal python manage.py backup --note "升级前"
# 列出已有备份(大小 / 条数 / 积分 / 来源 / 时间),--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:整体打包命名卷(升级前做,最完整)

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:逻辑导出(跨版本最安全,可读性最好)

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 一起回滚)→ 再弹一次最终确认。

# 命令行:不加 --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):

docker compose exec portal python manage.py restore <文件名> --yes --no-instance

方式 B:整体换卷(容器起不来时的兜底)

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 直接挂进去):

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 有没有破坏性变更(主版本 与标了 变更(不兼容) 的条目)。
  3. 记下当前版本: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 不需要
1.3.0 → 1.4.0(备份 + 对外加固) 3 → 4 只做两件事:users 加一列 session_ver(非空、默认 0)、建 backups 表(备份索引)。没有数据搬运、没有键位变动 不需要
1.4.0 → 1.5.0(界面改版 + 公网加固) 4 → 4 没有任何库结构变更,init_db() 只做幂等校验。改动都在代码与界面上 不需要

四次迁移都是幂等的(可重复启动、可重复执行),各自会写一条审计:

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 卷的体积

一键体检:

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 是好的:

# 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 自动清理)。

自检脚本(开发者向)

仓库自带三层验证,改完代码或升级后可以跑:

.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(导航里不显示)
/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; 若手工传过文件:

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

关键点:纯读也会触发,而且宿主进程退出后容器不会自愈 —— 只有重启容器才恢复。

处理:

  1. 根治(推荐):不要用 hostdir 叠加层,直接用默认的命名卷。 容器独占 /app/data,宿主侧一律通过 docker compose exec portal python manage.py … 操作。
  2. 临时:docker compose restart portal 立刻恢复,但下一次宿主访问会再坏一次。
  3. 想在宿主侧看数据:用 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

注意这只关调度。手动采集、manage.py collect、自动备份都还在跑 (自动备份由调度线程触发,所以关掉调度后自动备份也停了 —— 停调度期间请手动点几次「立即备份」)。

11.10 拿不到管理员初始口令

症状:第一次 docker compose up -d 之后想登录,不知道密码;日志里也找不到那行提示。

原因通常是没设 WB_ADMIN_PASSWORD(v1.4.0 起没有默认口令),而提示只在 「首次初始化那一刻」打印一次。依次试:

# 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)。

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 的方式 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)
WB_COOKIE_SECURE 0 1 = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 0)。同时是下发 HSTS 的开关之一
WB_TRUST_PROXY 0 1 = 信任 X-Forwarded-For(取最右侧合法 IP)。直接暴露公网必须留 0,见 第六节
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)
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)
    

    见 第六节。

  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):

    -- 例:清掉 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 上线前的自检

# 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