文件
wangchuanli 1bf961f6b3 feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 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。
2026-09-18 08:46:00 +08:00

505 行
21 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 常见问题(FAQ)
按「现象」分类。每条都给出**原因**和**动作**,不用从头排查。
---
## 一、部署
### Q:`docker compose up` 报端口被占用
```
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8848
```
改 `.env` 里的 `WB_PORT`,比如 `WB_PORT=18848`,然后 `docker compose up -d`。
容器内始终监听 8848,只改宿主映射即可。
### Q:容器起来了但局域网访问不了
1. `.env` 的 `WB_BIND` 是不是被设成了 `127.0.0.1`(那只允许本机);
2. 服务器防火墙有没有放行该端口;
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
### Q:登录成功却立刻又跳回登录页(循环)
99% 是 `WB_COOKIE_SECURE` 被开成了 `1`,而你在用 **HTTP** 访问。
会话 Cookie 加了 `Secure` 属性后,浏览器**只在 HTTPS 下才回传它**——于是服务端每次收到
请求都看不到会话,判定未登录,再把你送回登录页。日志里看起来是「一直在登录」。
```bash
# .env 里改回 0(纯局域网 HTTP 部署的正确值),然后重建容器
WB_COOKIE_SECURE=0
docker compose up -d
```
> 只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
> 注意 `TZ`、`WB_COOKIE_SECURE` 都是**环境变量**,`docker compose restart` 不生效,要 `up -d`。
### Q:容器 `unhealthy` 但 `Up`
```bash
docker compose exec portal python /app/docker/healthcheck.py
```
健康检查打的是 `/login`(唯一免登录页),拿到 200 才算健康。
若失败,看 `docker compose logs` 有没有 Python traceback。
### Q:`unable to open database file`
分三种情况,先判断你用的是哪种挂载:
| 场景 | 原因 | 处理 |
|---|---|---|
| 默认(命名卷) | 极少见;多半是卷被误删或磁盘满 | `docker compose exec portal ls -la /app/data`;`docker compose exec portal df -h /app/data` |
| 用了 `docker-compose.hostdir.yml`(**Linux**) | 宿主目录属主与容器内 uid 1000 不一致 | `sudo chown -R 1000:1000 ./data ./logs` |
| 用了 `docker-compose.hostdir.yml`(**Windows / Docker Desktop**) | 9p 挂载的固有缺陷,见下条 | 换成默认的命名卷 |
### Q:容器一开始好的,跑着跑着页面全 500,日志里 `unable to open database file`
**这是 Windows + Docker Desktop 用绑定挂载(hostdir 叠加层)时的典型症状。**
宿主机的 Windows 进程只要访问过这个 WAL 库——**哪怕只是 `manage.py stats` 这种纯读**——
容器侧下一次打开数据库就会失败,**而且不会自愈**。
```bash
docker compose restart portal # 临时恢复(但宿主再碰一次还会坏)
```
**根治**:不要用 `hostdir` 叠加层,直接用默认的命名卷;需要跑 CLI 就:
```bash
docker compose exec portal python manage.py stats
```
完整复现步骤与原理见
[部署与运维指南](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### Q:`database is locked` / `disk I/O error`
| 报错 | 原因 |
|---|---|
| `database is locked` | 有另一个写者(另一个容器实例?宿主机上同时在跑 `manage.py collect`?)。等它结束——文件锁会串行化,但 SQLite 层面的写冲突仍会短暂报错 |
| `disk I/O error` | 挂载的文件系统不支持 SQLite 需要的锁语义(某些 NFS / 网络盘)。改用本地盘或 Docker 命名卷 |
### Q:`exec format error` 或 `no such file or directory`(关于 entrypoint.sh)
`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'))"
```
### Q:能不能跑多个副本做高可用
**不要。** 三个理由:SQLite 是单写者;调度线程在 Web 进程内;文件锁只在本机有效。
真要跑多副本,除第一份外全部设 `WB_DISABLE_SCHEDULER=1`,但写冲突依然存在。
这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。
---
## 二、采集
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 4.2](USER-GUIDE.md#42-拿-cookie-的两种办法)),
填进「配置管理 → **我的云端凭证**」,保存后按区间补采。
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
### Q:采集被跳过,日志写 `no_cookie`
这个账号**还没配 Cookie**。多用户下每个账号要各自配一次——系统**不会**拿别人的 Cookie
替你采集(那会把两个人的数据混在一起)。到「配置管理 → 我的云端凭证」粘贴一份即可。
### Q:日志写 `cookie_broken` / 页面显示「无法解密」
数据库里的 Cookie 密文,用当前实例主密钥解不开了。通常是 `data/instance.json`
(存着 `cookie_key`)被删、被替换,或从别的机器拷了库过来。
**动作**:重新粘贴一次该账号的 Cookie,历史数据不受影响。
**预防**:备份时把 `data/instance.json` 和数据一起备份,别在容器之间混用。
> 这是**静态加密的正确行为**——密钥换了就该解不开;如果它「解不开也照样能用」,
> 那说明根本没加密。
### Q:采集成功但「新增 0 条」
大概率正常。看那一次的 `抓取` 条数:
| 抓取 | 新增 | 判断 |
|---|---|---|
| > 0 | 0 | 云端返回的都是库里已有的(断点回退窗口重叠)——**正常** |
| 0 | 0 | 该时段云端确实没有记录——**正常** |
| > 0 | > 0 | 正常采集到新数据 |
### Q:返回 409 `busy`
已有采集在跑。文件锁 `data/collect.lock` 保证同时只有一个采集。
到「任务管理 → 运行历史」看它是否还在 `running`,等结束再操作。
### Q:TLS / SSLError
企业代理或自签证书场景。两种处理:
1. **推荐**:把企业根证书装进系统信任链;
2. **临时**:「配置管理」把 `ssl_verify` 设为 `0`。
> Cookie 就是账号凭证,关掉证书校验等于把它暴露在中间人面前。**只在受控内网临时用。**
### Q:日志里出现「云端时间比本地早」告警
云端记录的 `cloud_ts` 比本地 `ts` 早超过 `drift_tolerance_minutes`(默认 5 分钟)。
影响不大,但可能意味着:
- 服务端时钟不准 → 校时;
- 云端写入有延迟 → 适当调大 `rewind_minutes`(比如 5)。
### Q:想让采集更频繁 / 更稀疏
「任务管理 → 每日时刻」改成任意逗号分隔的时刻,如 `08:30,12:30,18:00,22:00`。
保存即生效,不用重启。时刻按**本地时区**解释。
---
## 三、数据与日期
### Q:日期差一天 / 跨日数据落到相邻日期
时区问题。所有日期按服务端本地时区计算。
```bash
docker compose exec portal date # 期望:CST / +0800
docker compose exec portal python -c "from workbuddy_portal import db; print(db.now_str())"
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,然后 **`docker compose up -d` 重建**
(`TZ` 是环境变量,`restart` 不生效)。
### Q:导出的 CSV 在 Excel 里乱码
本系统导出的文件带 **UTF-8 BOM**,双击不会乱码。若乱码,先确认你打开的是
从「导出 CSV」拿到的文件,而不是用记事本另存过的版本。
### Q:大屏的「单笔 TOP」为什么切了日期区间也不变
**设计如此。** `top` 是**全局**的:如果跟着窗口变,排名会随筛选跳动,反而看不出长期最贵的那几条。
想要窗口内的排行,用「数据明细」按积分降序 + 日期筛选。
### Q:大屏的 `daily`(日历/趋势)为什么不受区间影响
也是设计如此:`daily` 是全量(约 200 B/天),日历与日期轴需要完整日期序列。
真正跟随窗口的是 `dims` / `totals` / 明细表。
### Q:明细表提示「只显示最近 N 条」
大屏下发的明细有上限(`recordsCap = 20000`)。超过时只发**最新 N 条**并置
`recordsTruncated=true`,页面据此提示。完整数据请到「数据明细」页筛选或导出。
---
## 四、界面
### Q:页面能开但图表全白
1. `Ctrl+F5` 强刷清缓存;
2. 开浏览器控制台看有没有资源 404;
3. 「日志管理 → 应用日志」看有没有异常栈。
> 历史上有过 `/dashboard` 下相对路径把 `echarts.min.js` 解析成 `/vendor/...` 导致
> 整页全白的问题,已在 `tools/smoke.py` 里加了「页面所有 `src`/`href` 资源逐个断言 200」防回归。
### Q:某块样式突然失效 / 文字发虚
多半是类名撞了全局样式。本项目约定:**新组件用带前缀的独有类名**
(如 `.calcell .cbar`,而不是含糊的 `.bar`)。
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言,跑一遍就能发现异常类名。
### Q:登录后跳转目标丢了
历史 bug,已修。现在 `next` 参数在 GET/POST 两条路径上都正确回填。
注意开放重定向防护会拒绝站外目标:`//evil.com`、`/\evil.com`、`https://evil.com`
一律回落到 `/`——这是**预期行为**。
---
## 五、账号与权限
### Q:怎么开放/关闭自助注册
「配置管理 → 实例级设置 → 开放自助注册」(**仅管理员可见**)。
打开后登录页会出现「自助注册」链接,任何人填表即可建号;关掉后只能由管理员在
「用户管理 → 新建账号」里建。默认是**开放**。
### Q:注册被拒 / 提示来源已达上限
同一个 IP 每天默认只能注册 3 个账号(`register_max_per_ip`,范围 1 ~ 50)。
这条限制是防批量刷号用的;换个来源,或由管理员调大。
### Q:验证码一直不对
按这个顺序排查:
| 现象 | 原因 |
|---|---|
| 刚才还能用,第二次就错 | 验证码**一次性**,用一次即废;输错也要重新取图 |
| 输得慢一点就错 | 有效期 **5 分钟**,过期即失效 |
| 拿登录页的码去注册 | 两个 `purpose` 的验证码**互不通用** |
| 明明对了还是被拒 | 该来源已被锁定(连续失败 5 次 → 锁 10 分钟) |
**动作**:点验证码图片换一张重来。想少费眼力,让管理员把「验证码位数」保持在 4 位。
> 验证码答案只存在服务端 `captchas` 表:下发到浏览器的是一个随机 `captcha_id`,
> 校验后无论成败都立刻删除。所以**在网页源码里搜不到答案**,抓图也拿不到复用的码。
### Q:验证码能关掉吗
「配置管理 → 实例级设置 → 验证码策略」有 `always` / `adaptive` / `off` 三档。
`adaptive` 只在同一来源**连续失败 2 次后**才要验证码,对天天登录的人更友好。
`off` 会显著放大撞库与批量注册的风险,**只有在前面已有可信网关时才考虑**。
### Q:忘记管理员密码
```bash
# 裸机
python manage.py passwd admin 新密码
# Docker
docker compose exec portal python manage.py passwd admin 新密码
```
不传新密码时会用默认的 `admin123`——**别这么干**。
账号被停用了要顺便恢复启用,加 `--activate`:
```bash
python manage.py passwd admin 新密码 --activate
```
想看现在有哪些账号、各自角色/状态/数据量/凭证状态:
```bash
python manage.py users
```
### Q:怎么给同事开账号
两种都行:
1. 让同事**自助注册**(需管理员开放注册);
2. 「用户管理 → 新建账号」,权限选**普通**(默认就是普通,管理员要显式选)。
普通用户能看概览/大屏/明细/任务/配置/日志,能改**自己的**凭证与采集参数、能触发采集与导出;
但看不到「用户管理」(访问 `/users` 返回 403),也改不了实例级设置(输入框置灰,接口也会拒)。
> 建完账号记得告诉同事:**要自己配一份自己的 Cookie**,否则采集不会跑(日志里是 `no_cookie`)。
### Q:管理员能看到别人的数据吗
**看不到。** 用户管理页只显示每个账号的记录条数与积分合计,点不进内容;
任何页面上 Cookie 都只回显「长度 + 结尾 4 位」。采集也只用本人凭证。
所以「把两个人的数据合起来看」要各自导出 CSV 再到外部合并——这是刻意的边界,不是缺陷。
### Q:误操作了别人账号 / 删错了人
- **停用**是可逆的:数据与 Cookie 都保留,随时可以再启用;
- **删除**不可逆:会连同该账号的用量数据与 Cookie 一起删。只能靠备份恢复
(见 [六、运维 · 备份](#q备份怎么做最稳))。
每次账号操作都会写 `audit_log`,在「用户管理 → 账号操作审计」里能查到谁在什么时候动的。
### Q:不小心把自己降级 / 删掉自己了
做不到。服务端有四条护栏:不能取消自己的管理员身份、不能停用自己、不能删除自己、
不能删掉最后一个启用的管理员。
### Q:所有人被踢下线了
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
> 同一个文件里还有 `cookie_key`(凭证加密主密钥),它变了会让**所有账号的 Cookie 都要重填**。
> 备份数据库时务必把 `instance.json` 一起备份。
---
## 六、运维
### Q:备份怎么做最稳
数据在命名卷 `workbuddy-portal_wb_data` 里。先 checkpoint 再拷出单个 `.sqlite`:
```bash
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
```
整体打包(含 `instance.json`、`exports/`):
```bash
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 .
```
恢复见 [部署指南 8.3](DEPLOYMENT.md#83-恢复)。
**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
### Q:数据库文件越来越大
```bash
docker compose exec portal python manage.py vacuum
```
或在「配置管理 → 维护动作 → 整理数据库」点一下(**这个按钮仅管理员可见**,
因为它动的是整库,不只你的数据)。
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
### Q:从 1.1.0 升级到 1.2.0 要做什么
**手工动作:零。** 首次启动新版本时会自动迁移:
1. 老数据整体归到**第一个账号**(也就是原来的那个唯一账号);
2. 原来明文存的 Cookie **就地加密**,日志里会记一条
`明文凭证已加密:settings[uid=1].cookie`;
3. 建 `captchas` 表、给各表补 `user_id` 列与索引。
### Q:从 1.2.0 升级到 1.3.0 要做什么
**同样零手工动作**,但会做一次**配置作用域收敛**,值得知道它改了什么:
| 做了什么 | 效果 |
|---|---|
| 把管理员个人名下的**调度与采集参数**提升到实例级(`user_id=0`) | 这些策略从此「一台部署一套」,对所有账号统一生效 |
| 清掉这些键在 `user_id<>0` 下的残留 | 不会再有「某个账号偷偷带着一份自己的旧调度时刻」 |
| 实例级不再保留 `cookie` / `user_agent` | 凭证只属于个人;实例级存凭证等于给所有账号发同一张身份 |
审计里会留一条 `promote_global_settings`,日志里能看到「配置作用域收敛」。
迁移**幂等**,可重复启动。
**升级后建议确认两件事:**
1. 各账号的 Cookie 仍然「已配置」(能解密)——「配置管理 → 我的云端凭证」;
2. 用一个普通账号登录,「任务管理」里的调度时刻应当是**只读**的,
点保存会被拒并点名越权项。
> 如果你的旧部署里**不同账号原本设了不同的采集时刻**,升级后会统一成管理员那一刻。
> 这是刻意的(一台部署一个时刻表,避免多个采集抢同一把写锁),
> 但需要提前知会使用者:他们之后要改时刻得找管理员。
看迁移结果:
```bash
docker compose logs portal | grep -iE "迁移|migrat|收敛|promote"
docker compose exec portal python manage.py users
docker compose exec portal python manage.py stats
```
**升级前务必备份**(含 `instance.json`):迁移会改主键与索引,虽然实现了回滚失败即中止,
但备份永远是第一道保险。
### Q:升级会不会丢数据
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
`docker compose up -d --build` 只重建容器,不碰卷。
但**升级前依然要备份**(见上一条)。
> 迁移用 `PRAGMA user_version` 记录版本,**幂等**:重复启动不会重复迁移。
> 改列这种操作现在也由 `init_db()` 自动完成,不再是「需要手工迁移」。
> 想确认当前库结构版本:
> ```bash
> docker compose exec portal python -c "
> from workbuddy_portal import db; c = db.connect()
> print('user_version =', c.execute('PRAGMA user_version').fetchone()[0])"
> ```
> 1.2.0 是 `2`,1.3.0 是 `3`。
### Q:日志在哪、怎么滚动
| 位置 | 内容 | 谁能看 |
|---|---|---|
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) | 能登服务器的人 |
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 | 能登服务器的人 |
| 页面「日志管理」 | 全实例采集逐行日志 + 应用日志尾部 + 操作审计 | **仅管理员** |
| 页面「任务管理 → 运行历史」 | **你自己**的采集记录(触发方式、耗时、条数) | 所有登录用户 |
> 普通账号看不到「日志管理」页(导航里不显示,直接敲 `/logs` 返回 403)。
> 自己排错先用「任务管理 → 运行历史」,需要逐行日志时找管理员。
### Q:想改采集的接口地址(走镜像/代理)
「配置管理 → 采集参数」里改 `api_base` 与 `api_path`。这两个是**实例级**键,
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
改了之后记得同步确认 Cookie 是该域下的有效凭证。
---
## 七、开发
### Q:改完代码怎么验证
**五层,前两层必须跑绿**:
```bash
python tools/smoke.py # 离线回归 215 项(不需要起服务)
python tools/check_docs.py # 文档自检(改过任何 md 就跑)
python tools/demo_data.py # 可选:造一份合成示例库
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 122 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
```
> 对**示例库**跑 `check_live.py` 时记得加 `--db data/demo/usage.sqlite` ——
> 它的 `--db` 默认值是真实的 `data/usage.sqlite`,不传会从错误库里取验证码答案,
> 症状是「登录失败」加一串看不懂的断言失败。
两个新增参数值得一提:
- `check_live.py --db <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录;
- `shots.py --db <路径>`:同上,截图脚本自动解开登录页与注册页。
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
### Q:改了多用户的代码,怎么确认没越权
三个低成本自查:
1. 在 `smoke.py` 的隔离小节里加一条断言 —— 调用 `query.*` 时**故意漏掉 `uid`**,
期望它抛 `TypeError`(本项目把 `uid` 设计成「`conn` 之后的第一个位置参数、无默认值」,
漏传就炸,不会静默返回全量);
2. 临时建一个普通账号,把 `WB_DISABLE_SCHEDULER` 之类放一边,直接访问 `/users` 与
`POST /api/settings` 写实例级键,都应该是 403;
3. 写一个**哨兵值**(如 `SMOKE-SENTINEL-UA`)到实例级 `user_agent`,
断言新账号读不到它——这一招能抓到「落回落到别人配置上」的越权。
### Q:`ModuleNotFoundError: No module named 'flask'`
选错解释器了。依赖装在项目的 venv 或托管环境里:
```bash
python -c "import flask, sys; print(sys.executable, flask.__version__)"
```
报这个错说明当前 `python` 不是装了依赖的那个。
### Q:改模板后页面没变
本项目 `TEMPLATES_AUTO_RELOAD=True`,模板改动通常立即生效。
若是静态资源(CSS/JS)被浏览器缓存,`Ctrl+F5`。
### Q:写了个自定义接口结果 500,但日志只看到异常栈
先看是不是**流式响应**(`Response(gen())` / `stream_with_context`):
Flask 在返回 `app_iter` 之后就关掉了请求上下文里的连接,
生成器里若复用 `db.get_db()` 会报 `Cannot operate on a closed database`。
正确做法是在生成器内部 `db.connect()` 自建连接并 `finally` 关闭。
详见 [架构说明 · 已知坑](ARCHITECTURE.md#九已知坑与红线)。