文件
workbuddy-portal/docs/FAQ.md
T
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

21 KiB
原始文件 Blame 文件历史

常见问题(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 下才回传它——于是服务端每次收到 请求都看不到会话,判定未登录,再把你送回登录页。日志里看起来是「一直在登录」。

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

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 这种纯读—— 容器侧下一次打开数据库就会失败,而且不会自愈。

docker compose restart portal          # 临时恢复(但宿主再碰一次还会坏)

根治:不要用 hostdir 叠加层,直接用默认的命名卷;需要跑 CLI 就:

docker compose exec portal python manage.py stats

完整复现步骤与原理见 部署与运维指南。

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; 若手工传过文件:

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,但写冲突依然存在。 这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。


二、采集

Cookie 过期。重新获取(见 用户手册 4.2), 填进「配置管理 → 我的云端凭证」,保存后按区间补采。

Cookie 通常是浏览器会话级,关掉浏览器可能就失效。从已登录浏览器复制时勾选「保持登录」。

这个账号还没配 Cookie。多用户下每个账号要各自配一次——系统不会拿别人的 Cookie 替你采集(那会把两个人的数据混在一起)。到「配置管理 → 我的云端凭证」粘贴一份即可。

数据库里的 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:日期差一天 / 跨日数据落到相邻日期

时区问题。所有日期按服务端本地时区计算。

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:忘记管理员密码

# 裸机
python manage.py passwd admin 新密码

# Docker
docker compose exec portal python manage.py passwd admin 新密码

不传新密码时会用默认的 admin123——别这么干。

账号被停用了要顺便恢复启用,加 --activate:

python manage.py passwd admin 新密码 --activate

想看现在有哪些账号、各自角色/状态/数据量/凭证状态:

python manage.py users

Q:怎么给同事开账号

两种都行:

  1. 让同事自助注册(需管理员开放注册);
  2. 「用户管理 → 新建账号」,权限选普通(默认就是普通,管理员要显式选)。

普通用户能看概览/大屏/明细/任务/配置/日志,能改自己的凭证与采集参数、能触发采集与导出; 但看不到「用户管理」(访问 /users 返回 403),也改不了实例级设置(输入框置灰,接口也会拒)。

建完账号记得告诉同事:要自己配一份自己的 Cookie,否则采集不会跑(日志里是 no_cookie)。

Q:管理员能看到别人的数据吗

看不到。 用户管理页只显示每个账号的记录条数与积分合计,点不进内容; 任何页面上 Cookie 都只回显「长度 + 结尾 4 位」。采集也只用本人凭证。

所以「把两个人的数据合起来看」要各自导出 CSV 再到外部合并——这是刻意的边界,不是缺陷。

Q:误操作了别人账号 / 删错了人

  • 停用是可逆的:数据与 Cookie 都保留,随时可以再启用;
  • 删除不可逆:会连同该账号的用量数据与 Cookie 一起删。只能靠备份恢复 (见 六、运维 · 备份)。

每次账号操作都会写 audit_log,在「用户管理 → 账号操作审计」里能查到谁在什么时候动的。

Q:不小心把自己降级 / 删掉自己了

做不到。服务端有四条护栏:不能取消自己的管理员身份、不能停用自己、不能删除自己、 不能删掉最后一个启用的管理员。

Q:所有人被踢下线了

SECRET_KEY 变了。它存在 data/instance.json。这个文件丢了/被删了就会重新生成, 所有会话失效(数据不受影响)。恢复办法:从备份里找回 instance.json,或让大家重新登录。

同一个文件里还有 cookie_key(凭证加密主密钥),它变了会让所有账号的 Cookie 都要重填。 备份数据库时务必把 instance.json 一起备份。


六、运维

Q:备份怎么做最稳

数据在命名卷 workbuddy-portal_wb_data 里。先 checkpoint 再拷出单个 .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

整体打包(含 instance.json、exports/):

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。 别把新库配旧 WAL 用——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。

Q:数据库文件越来越大

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. 用一个普通账号登录,「任务管理」里的调度时刻应当是只读的, 点保存会被拒并点名越权项。

如果你的旧部署里不同账号原本设了不同的采集时刻,升级后会统一成管理员那一刻。 这是刻意的(一台部署一个时刻表,避免多个采集抢同一把写锁), 但需要提前知会使用者:他们之后要改时刻得找管理员。

看迁移结果:

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() 自动完成,不再是「需要手工迁移」。 想确认当前库结构版本:

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:改完代码怎么验证

五层,前两层必须跑绿:

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 <路径>:同上,截图脚本自动解开登录页与注册页。

详见 架构说明 · 验证体系。

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 或托管环境里:

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 关闭。 详见 架构说明 · 已知坑。