chore: 项目定名为 workbuddy-portal,容器化并补齐文档体系

## 项目定名
- 目录 wb_usage_portal → workbuddy-portal
- Python 包 wb_usage → workbuddy_portal(含 session cookie 名)
- 界面品牌统一为 WorkBuddy Portal;项目标识收敛到 config 单一来源

## 容器化
- Dockerfile:多阶段构建,依赖层与源码解耦;非 root(uid 1000);内置健康检查
- docker-compose.yml:单服务 + 绑定挂载 data/logs + 日志轮转 + TZ
- docker/entrypoint.sh:幂等初始化 → exec serve(LF 行尾,已由 .gitattributes 锁定)
- docker/healthcheck.py:纯标准库探活 /login(slim 镜像无 curl)
- .dockerignore / .env.example;数据目录可用 WB_DATA_DIR 等环境变量覆盖

## 文档
- docs/USER-GUIDE.md    用户使用手册(含 9 张真实界面截图)
- docs/DEPLOYMENT.md    部署运维(Docker / 裸机 / 反代 / 备份 / 推 Gitea 注册表)
- docs/ARCHITECTURE.md  架构与设计说明(含已知坑与红线、验证体系)
- docs/API.md           接口参考(路径 / 参数 / 返回结构 / 错误码)
- docs/FAQ.md           常见问题;docs/CHANGELOG.md 变更日志

## 修复缺陷(8)
1. /records/export 必然 500:生成器在请求上下文销毁后才迭代,改用自建连接
2. 大屏页图表全白:相对路径把 echarts.min.js 解析成 /vendor/... → 404
3. /users 500:路由已注册但模板缺失
4. 明细页日期筛选失效:视图传 f.frm、模板读 f.from
5. 配置页维护按钮全死:调用了不存在的 WBU.bindMaint()
6. 审计只能看最近 40 条:LIMIT 写死
7. 明细页多跑一条无用 SELECT:day_list() 取了没人用
8. 登录页锁定阈值未从配置注入

## 安全加固
- 新增 safe_next():拒绝 //evil.com 等协议相对 URL 的开放重定向
- 缺 CSRF 的写请求统一 400
- 默认开启云端 HTTPS 证书校验(ssl_verify=1);Cookie 是账号凭证
- 登录失败计数表加上限与 TTL
- /logout 拆分为 POST(执行) + GET(仅提示),防 <img src=/logout> 静默退出
- settings 内部簿记键 slot:* 读写两侧过滤,不再从 /api/settings 泄漏

## 内部质量与工具
- 设置项写时校验 + 读时兜底,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 ValueError → 400:手写 query string 不再暴露 500 页面
- CSV 导出改 csv.writer 流式写入(原手工拼串,字段含逗号会串列)
- bundle 明细加 20000 上限并回传 recordsTotal/recordsTruncated,不静默丢数据
- tools/smoke.py 离线回归 99 项;tools/check_live.py 真实 HTTP 56 项
- tools/shots.py Playwright 逐页截图 + JS 报错收集

## 验证
- compileall 通过;smoke 99/99;对容器实例 check_live 56/56;截图 0 JS 报错
- 容器内采集实测成功(trigger=startup 补跑:新增 11 条)
这个提交包含在:
2026-09-14 14:55:50 +08:00
当前提交 86631ae7ab
共修改 58 个文件,包含 9409 行新增和 0 行删除
+475
查看文件
@@ -0,0 +1,475 @@
# 部署与运维指南
> 面向**运维 / 部署者**。从零到跑起来,以及跑起来之后的备份、升级、排错。
**目录**
- [一、部署方式怎么选](#一部署方式怎么选)
- [二、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 http://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_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
### 2.4 数据落点
| 容器内 | 宿主机 | 内容 |
|---|---|---|
| `/app/data` | `./data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` |
| `/app/logs` | `./logs` | `app.log`(滚动 2 MB × 3) |
**绑定挂载**而非命名卷,是为了:备份就是拷目录;宿主机上的 `manage.py` 能直接读同一份数据。
> **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 流式下载都可用。
---
## 三、裸机部署
### 3.1 Windows
```bat
git clone http://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 http://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.1 准备:本机允许 HTTP 注册表
Gitea 走的是 **HTTP**,Docker 默认只允许 HTTPS。Docker Desktop:**Settings → Docker Engine**,
在 `daemon.json` 里加:
```json
{
"insecure-registries": ["git.iwali.top"]
}
```
`Apply & Restart`。Linux 上同理改 `/etc/docker/daemon.json` 后 `sudo systemctl restart docker`。
> 这是**内网自托管服务**的常规做法。若 Gitea 前面有带证书的 Caddy/nginx,
> 用 `https://` 地址即可,不必开 insecure。
### 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
volumes:
- ./data:/app/data
- ./logs:/app/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 \
http://git.iwali.top/api/v1/packages/wangchuanli?type=container
```
---
## 六、备份与恢复
### 6.1 备份什么
| 文件 | 重要性 | 说明 |
|---|---|---|
| `data/usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
| `data/usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
| `data/instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
| `data/exports/*.csv` | ★ | 导出快照,可再生 |
| `logs/` | ☆ | 排错用,可再生 |
`.env` 不在里面——它含密码,**单独用密码管理器保管**。
### 6.2 备份命令
```bash
# 方式 A:先 checkpoint 再拷(推荐,最干净)
docker compose exec portal python -c "
from workbuddy_portal import db
c = db.connect(); c.execute('PRAGMA wal_checkpoint(TRUNCATE)')
"
# Windows 宿主机
copy data\usage.sqlite D:\backup\usage-%DATE%.sqlite
# Linux 宿主机
cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite
# 方式 B:直接整体拷(含 -wal / -shm)
docker compose stop portal
tar czf backup-$(date +%F).tar.gz data/
docker compose start portal
# 方式 C:逻辑导出(跨版本最安全)
docker compose exec portal python manage.py export-csv /app/data/exports
```
建议方式 A 配合计划任务每天跑一次;每季度用方式 C 出一份逻辑快照。
### 6.3 恢复
```bash
docker compose down
cp ~/backup/usage-2026-09-14.sqlite data/usage.sqlite
rm -f data/usage.sqlite-wal data/usage.sqlite-shm # 关键:清掉旧 WAL
docker compose up -d
docker compose exec portal python manage.py stats # 核对条数
```
> **别把旧库和旧 WAL 混着用**。WAL 里记的是相对旧库的增量,配错会损坏数据。
---
## 七、升级与回滚
### 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. **先备份**(见第六节)——`schema.sql` 用的是 `CREATE TABLE IF NOT EXISTS`,
加表加索引是安全的,但改列需要手工迁移,所以备份是唯一保险。
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。
---
## 八、日常巡检
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
| 每周 | 「日志管理」按 `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` 了;防火墙有没有放行 |
### `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` | 目录属主不对:`sudo chown -R 1000:1000 ./data` |
| `database is locked` | 有另一个写进程(另一个容器?宿主机上的 CLI?);等它跑完 |
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 |
### 采集相关
| 报错 | 处理 |
|---|---|
| `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
```
---
## 十、配置项速查
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
也可以直接改表):
| 键 | 默认 | 说明 |
|---|---|---|
| `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 证书 |
| `api_base` / `api_path` | 官方地址 | 接口地址(走镜像/代理时改) |
| `cookie` | 空 | 账号凭证(页面只回掩码) |
| `user_agent` | Chrome UA | 与 Cookie 同源更稳 |
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。
环境变量(启动期,改了要重建容器):
| 变量 | 说明 |
|---|---|
| `TZ` | 时区,影响所有日期口径 |
| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 |
| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) |
| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 |