数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
19 KiB
常见问题(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:容器起来了但局域网访问不了
.env的WB_BIND是不是被设成了127.0.0.1(那只允许本机);- 服务器防火墙有没有放行该端口;
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,但写冲突依然存在。
这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。
二、采集
Q:采集报 cookie_expired / unauthorized
Cookie 过期。重新获取(见 用户手册 3.2), 填进「配置管理 → 我的云端凭证」,保存后按区间补采。
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
企业代理或自签证书场景。两种处理:
- 推荐:把企业根证书装进系统信任链;
- 临时:「配置管理」把
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:页面能开但图表全白
Ctrl+F5强刷清缓存;- 开浏览器控制台看有没有资源 404;
- 「日志管理 → 应用日志」看有没有异常栈。
历史上有过
/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:怎么给同事开账号
两种都行:
- 让同事自助注册(需管理员开放注册);
- 「用户管理 → 新建账号」,权限选普通(默认就是普通,管理员要显式选)。
普通用户能看概览/大屏/明细/任务/配置/日志,能改自己的凭证与采集参数、能触发采集与导出;
但看不到「用户管理」(访问 /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 .
恢复见 部署指南 6.3。 别把新库配旧 WAL 用——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
Q:数据库文件越来越大
docker compose exec portal python manage.py vacuum
或在「配置管理 → 维护动作 → 整理数据库」点一下(这个按钮仅管理员可见,
因为它动的是整库,不只你的数据)。
作用是 wal_checkpoint(TRUNCATE) + VACUUM,回收删除后的空闲页并压缩 WAL。
Q:从 1.1.0 升级到 1.2.0 要做什么
手工动作:零。 首次启动新版本时会自动迁移:
- 老数据整体归到第一个账号(也就是原来的那个唯一账号);
- 原来明文存的 Cookie 就地加密,日志里会记一条
明文凭证已加密:settings[uid=1].cookie; - 建
captchas表、给各表补user_id列与索引。
看迁移结果:
docker compose logs portal | grep -i migrate
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()自动完成,不再是「需要手工迁移」。
Q:日志在哪、怎么滚动
| 位置 | 内容 |
|---|---|
docker compose logs portal |
容器 stdout(entrypoint + waitress) |
命名卷 workbuddy-portal_wb_logs 里的 app.log |
应用日志,滚动 2 MB × 3 |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
Q:想改采集的接口地址(走镜像/代理)
「配置管理 → 采集参数」里改 api_base 与 api_path。这两个是实例级键,
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
改了之后记得同步确认 Cookie 是该域下的有效凭证。
七、开发
Q:改完代码怎么验证
五层,前两层必须跑绿:
python tools/smoke.py # 离线回归 165 项(不需要起服务)
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 83 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
两个新增参数值得一提:
check_live.py --db <路径>:让它直接读库里的验证码答案,从而自动过验证码登录;shots.py --db <路径>:同上,截图脚本自动解开登录页与注册页。
详见 架构说明 · 验证体系。
Q:改了多用户的代码,怎么确认没越权
三个低成本自查:
- 在
smoke.py的隔离小节里加一条断言 —— 调用query.*时故意漏掉uid, 期望它抛TypeError(本项目把uid设计成「conn之后的第一个位置参数、无默认值」, 漏传就炸,不会静默返回全量); - 临时建一个普通账号,把
WB_DISABLE_SCHEDULER之类放一边,直接访问/users与POST /api/settings写实例级键,都应该是 403; - 写一个哨兵值(如
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 关闭。
详见 架构说明 · 已知坑。