文件
workbuddy-portal/docs/CHANGELOG.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

320 行
21 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 变更日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/):`主版本.次版本.修订号`。
- **主版本**:不兼容的变更(数据库迁移需手工介入、接口契约变化)
- **次版本**:向后兼容的功能新增
- **修订号**:向后兼容的缺陷修复
---
## [1.3.0] — 2026-09-16
**主题:权限收敛 · 配置作用域统一 · 目录规范化**
把「配置存在哪一级」与「谁能改它」对齐成一条规则,并收紧普通账号的越权面。
**数据不会丢**:`manage.py init` 检测到 `PRAGMA user_version` 2 → 3 时自动迁移,
把管理员个人名下的调度与采集参数**提升到实例级**(`user_id=0`)后清掉个人残留,
全程写一条 `promote_global_settings` 审计,且可重复执行。
### 变更(**不兼容**)
- **调度与采集参数改为实例级,普通账号只读**
- `GLOBAL_KEYS` 扩容:`api_base` / `api_path` / 注册策略 4 键
+ `schedule_enabled` / `schedule_times` / `catch_up` / `catch_up_grace_hours`
+ `page_size` / `rewind_minutes` / `drift_tolerance_minutes` / `max_prompt` /
`verify_days` / `timeout` / `ssl_verify`
- 新增 `config.USER_EDITABLE_KEYS = {cookie, user_agent}` —— 普通账号**唯一**可写的两个键
- 新增 `config.writable_by(key, is_admin)`:前后端与测试共用的**唯一**判断入口,
避免「页面置灰但接口还能写」这类规则漂移
- 为什么必须放实例级(而不是「个人级但只有管理员能写」):若只写在管理员自己的
`user_id` 下,其它账号读取时会回落到 `DEFAULTS`,**管理员改的值对别人完全不生效**
—— 那是个静默 bug。统一放实例级,语义是「一台部署一套采集与调度策略」。
- **`set_setting()` 强制把全局键重定向到 `user_id=0`**:从结构上消除
「管理员改了只有自己生效」的可能
- **`/logs` 与 `/logs/tail` 改为仅管理员**(原先普通账号能看到自己的采集历史 +
整机应用日志尾部)。普通账号访问返回 403,导航里不显示入口
### 新增
- **`manage.py init` 自动迁移(uv 2 → 3)**:`_promote_personal_to_global(conn)`
取首个管理员的个人级全局键值提升到 `user_id=0`,再清除 `user_id<>0` 的残留;
幂等,可反复执行
- **凭证类键刻意不灌实例级**:`init_db()` 灌默认值时 `continue` 掉
`USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')`
—— 实例级存凭证等于给所有账号发同一张身份
- **`/api/settings` 回传 `_userKeys` / `_role`**,`/api/manifest` 回传 `role`,
`/api/status` 新增 `is_admin` / `can_edit_schedule` / `can_view_logs`
—— 大屏是静态页,拿不到 Jinja 上下文,只能靠这几个字段决定显隐
- **`app.js:formData()` 跳过 disabled 控件**(含祖先 `fieldset[disabled]`):
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」
拒掉**整单**;现在只读项既不显示也不参与提交
- **测试**:`tools/smoke.py` 162 → 215 项断言(新增「非管理员越权面必须全部关死」
与「全局键必须落在实例级」两节);`tools/check_live.py` 83 → 122 项,
新增 `--as USER:PASS` 参数与第 12 节「普通账号真实 HTTP 越权验收」
- **新增 `tools/check_docs.py`(文档自检)**:内部链接与**跨文件锚点**、图片引用、
**绝对路径泄漏**(连带会泄漏用户名)、版本一致性(`__init__` / `Dockerfile` /
`README` / `CHANGELOG` 四处)、模板与 JS 里的产品名硬编码。
文档互相引用后章节一重排,锚点会**静默失效** —— Markdown 自己不报错、CI 也不管,
只能靠这一层。**有问题即退出码 1**(只想看报告不失败用 `--no-fail`)。
文档清单**自动发现**,不写死文件名 —— 写死列表的那版曾漏掉
`THIRD-PARTY-NOTICES.md` / `CODE_OF_CONDUCT.md`(覆盖面 13 → 9 个文件且毫无提示)。
锚点比对**刻意不逐字复刻 GitHub/Gitea 的 slug 算法**(各家对 `+`/`:`/连续空格的
处理并不一致,写死一个实现换个托管平台就批量误报),改为只比较「有效字符」
(小写字母 / 数字 / 汉字),既不受标点差异干扰,章节真被改名时又照抓不误
### 修复
- **`api_status` 引用了未定义的变量 `u`** ⇒ `/api/status` 稳定 500。
该缺陷由 `check_live.py` 新增的真实 HTTP 验收抓到,此前 smoke 完全没有覆盖这个接口
- **`tools/smoke.py` 的清理语句会把实例级配置当孤儿删掉**
(`DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`,
而 `user_id=0` 不是任何真实账号)⇒ 每跑一轮 smoke 就清空一次实例级配置。
实测曾把 19 个实例级键清到只剩 1 行。已加 `user_id<>0 AND` 并补防回归断言
- **`tools/smoke.py` 哨兵 UA 还原会留下多余行**:原本实例级无 `user_agent` 行时
`set_setting(..., "")` 会插一行空串。改为「原本无则 DELETE」,断言也改成比行为而非比行
### 目录规范化
- 工作区根目录的 v1.0 单文件版(`fetch_usage.py`、`dashboard/`、`data/usage_records.csv`)
收进工作区级 `legacy-v1/`,附带 README 说明「已被取代、可安全删除」;
`config.LEGACY_CSV_CANDIDATES` 第一候选同步指向新位置
- 新增 `backups/` 作为数据库快照的统一落点(刻意**不放在 `data/`**——
`data/` 是 Docker 卷,`down -v` 会把备份和正本一起删掉)
- `.gitignore` 补 `backups/*`、`data/*.bak*`、`legacy-v1/` 三条兜底规则
- 清理 `data/shots/`(已被 `docs/images/` 取代)与全部 `__pycache__`
### 文档
- `docs/DEPLOYMENT.md` 重写:三条并列的部署路径(裸机 / Docker 自打包 /
docker-compose 拉云端镜像),配置项速查表按新作用域重排
- `docs/USER-GUIDE.md` 重写:新增「权限与数据边界」「信息安全与隐私安全」两章,
截图重出为普通账号视角
- `README.md` / `SECURITY.md` / `CONTRIBUTING.md` / `docs/ARCHITECTURE.md` /
`docs/API.md` / `docs/FAQ.md` 同步权限模型、配置作用域与验证层变化;
订正了 CONTRIBUTING / ARCHITECTURE / FAQ 里残留的旧断言数(165/83 → 215/122)
---
## [1.2.0] — 2026-09-15
**主题:多用户化 · Cookie 加密 · 开放注册与验证码**
从单用户版升级到多用户版。**数据不会丢**:`manage.py init` 会自动检测旧表结构并迁移
(`PRAGMA user_version` 0 → 2),历史用量归到首个账号、明文 Cookie 就地加密,
全程写一条 `schema_migrate` / `encrypt_secrets` 审计,且可重复执行。
### 新增
- **多用户与数据隔离**
- `users` 表补齐 `email` / `status` / `register_ip` / `last_login_ip`;
`settings` 主键改为 `(user_id, key)`,`usage_records` 改为 `(user_id, request_id)`,
全部索引以 `user_id` 打头;`collect_runs` / `audit_log` 增加 `user_id`
- `query.py` / `collect.py` / `scheduler.py` 全链路把 `uid` 作为 `conn` 之后的
**第一个位置参数且无默认值** —— 漏传直接 `TypeError`,不会退化成「返回全量」
- 配置三级回落:`个人 → 实例(user_id=0) → config.DEFAULTS`;
新增 `NO_FALLBACK_KEYS = {cookie, user_agent}`,凭证**永不回落**(回落即串号越权)
- `scheduler.tick()` 遍历启用账号逐个判断槽位;未配 Cookie 的账号自动跳过
- 新增 `manage.py users` / `stats -u` / `status`(逐账号)/ 各子命令的 `-u/--user`
- **Cookie 静态加密**(`workbuddy_portal/crypto.py`,约 190 行,**零第三方依赖**)
- 手写 ChaCha20 块函数(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,
密文格式 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`,已用官方测试向量逐字节验证
- 主密钥 `cookie_key` 独立存放在 `data/instance.json`(与 `SECRET_KEY` 分开键位)
- `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把加密键一律置空;
`db.secret_state()` 只回 `{set, chars, tail, broken}`,绝不含明文
- `decrypt()` 对非 `v1.` 前缀原样返回(兼容历史明文,下次写入自动升级),
校验失败**抛异常**而不是「失败就返回原值」;升级时自动把历史明文加密
- **开放注册**:`/register` 页 + `POST /api/users`(管理员);开关 `allow_register`、
同 IP 每日配额 `register_max_per_ip`;用户名/密码强度校验(保留字黑名单、≥8 位且两类字符)
- **图形验证码**(`workbuddy_portal/captcha.py`,约 250 行,零第三方依赖)
- **手写 PNG 编码器**(zlib 压缩 IDAT)+ 5×7 点阵字模 + Bresenham 干扰线与噪点。
刻意不用 SVG —— SVG 是文本,答案会明文出现在页面源码里
- 答案只写服务端 `captchas` 表;会话里仅存随机 id;**一次性、5 分钟过期、按用途隔离**
- 策略 `captcha_policy`:`always`(默认)/ `adaptive`(同来源失败 2 次后要求)/ `off`
- 登录**先验验证码再比口令**(否则攻击者能拿「密码对不对」当信号提前跑完字典)
- **安全加固**
- 失败限速改为 **IP + 用户名双维度**,任一超限即锁;新增验证码出图限速(60s/40 张)
- `current_user()` 每请求回查 `users.status` ⇒ 停用账号**立即**失效,不必等会话过期
- 安全响应头:CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` / COOP;
`/api/*` 与 `/captcha*` 带 `no-store`
- 会话 cookie 显式 `HttpOnly` + `SameSite=Lax` + `Path=/`;`WB_COOKIE_SECURE=1` 可开 Secure
- `/logs/tail` 改为**仅管理员**;管理员不能停用/降权/删除自己
- **页面**:新增 `/login` 验证码、`/register`、`/profile`(个人中心,点右上角用户名进入);
`/config` 增加凭证状态与 `cookie_broken` 告警、实例级设置区;`/users` 增加邮箱/状态列与启停
### 变更
- 接口新增:`GET/POST /api/profile`、`POST /api/captcha`(机制自述)、
`POST /api/users/<id>/delete`;`GET /api/settings` 回传 `_globalKeys` / `_canEditGlobal`
- `/api/collect` 未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken`
(不再静默当成「未配置」)
- `/records/export` 与 CLI `export-csv` 的默认文件名带账号名(多用户下同名会互相覆盖)
- `WB_COOKIE` 环境变量兜底**已移除** —— 它会导致串号
### 修复
- `db.get_db()` 在流式响应里被复用导致 `Cannot operate on a closed database`
(生成器内部改为自建连接)
- `.dockerignore` 的 `__pycache__/` 只匹配上下文根目录,嵌套目录会被打进镜像
- 注册成功提示与注册页说明里的 `**强调**` 字面量(HTML 不解析 Markdown)
- **`base.html` 顶层的 `{% set me = current_user() %}` 会覆盖子模板传入的同名变量**
—— 而 `current_user()` 只含 `id/username/display_name/is_admin`,于是个人中心把
`me.created_at` 渲染成空(「注册于 ·」)。局部变量改名 `cur`,`smoke.py` 加 3 条防回归断言
- `WB_COOKIE_SECURE` 没有写进 `docker-compose.yml` 的 `environment:`
—— 在 `.env` 里设了也不生效,文档里的开关实际是哑的(已补上,并加进 `.env.example`)
- 「配置管理 → 修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,而页面只有确认框、必删数据,措辞改为与实际一致
### 升级提示
- 纯 HTTP 局域网部署**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie,
表现为「刚登录完又被弹回登录页」
- 迁移后请到「配置管理」确认 Cookie 状态;`secret_state.broken = true` 说明
`data/instance.json` 里的 `cookie_key` 与写入时不一致,重新粘贴一次即可
---
## [1.1.0] — 2026-09-14
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
### 新增
- **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、
`docker-compose.yml`(单服务、**数据放 Docker 命名卷**、健康检查、日志轮转)、
`docker-compose.hostdir.yml`(可选叠加层:把数据/日志放到宿主机目录,仅建议 Linux)、
`docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、
`.dockerignore`、`.env.example`
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
`WB_ADMIN_USER` `WB_ADMIN_PASSWORD` `WB_DISABLE_SCHEDULER` `WB_IMPORT_CREDS` `WB_IMPORT_XLSX`
- **文档体系** `docs/`:
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图,**全部用合成示例数据渲染**)、
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
[架构与设计说明](ARCHITECTURE.md)、
[接口参考](API.md)、
[常见问题](FAQ.md)
- **开源声明体系**(仓库根目录):[LICENSE](../LICENSE)(MIT)、
[THIRD-PARTY-NOTICES.md](../THIRD-PARTY-NOTICES.md)(依赖清单与再分发合规自查)、
[CONTRIBUTING.md](../CONTRIBUTING.md)(含「必须遵守的不变量」与五层自检方法)、
[SECURITY.md](../SECURITY.md)、[CODE_OF_CONDUCT.md](../CODE_OF_CONDUCT.md)、
`.github/` 下的 Issue 表单与 PR 模板、`.editorconfig`,
以及给全部 Python / Shell 源文件加 `SPDX-License-Identifier: MIT` 头
- **`tools/demo_data.py`**:生成**完全合成**的示例库(假模型名 / 假 Prompt / 偏斜的积分分布),
写入 `data/demo/`(已在 `.gitignore` 内)。文档截图与本地调试都基于它,
任何人不需要真实账号就能复现整套界面
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
### 变更
- **文档数据脱敏**:9 张界面截图全部改用合成示例数据重拍;
`docs/API.md`、`docs/DEPLOYMENT.md` 示例响应里的真实模型名与真实统计数字一并替换为示例口径。
此前截图中含**真实 Prompt 全文**、本机路径与本机用户名,属于不该公开的内容
- **`.gitignore` 补强**:只写 `data/*.sqlite` 会漏掉子目录,改为同时保留 `data/**/*.sqlite`
等规则,并新增 `data/demo/` 忽略——否则 `tools/demo_data.py` 的产物会被误提交
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
- **界面品牌**统一为 **WorkBuddy Portal**(此前为「WorkBuddy 用量门户」)
- 项目标识收敛到 `config.PROJECT_NAME` / `PROJECT_TITLE` / `PROJECT_DESC` 单一来源,
模板通过 `app_name` 等上下文变量引用,不再多处硬编码
- 数据 / 日志目录支持环境变量覆盖(`WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB`)
- 版本号 1.0.0 → **1.1.0**
- 大屏页标题改为「WorkBuddy Portal · 积分消耗大屏」
### 修复
| # | 症状 | 根因 |
|---|---|---|
| 1 | `/records/export` **必然 500** | 生成器在请求上下文销毁后才被迭代,复用 `db.get_db()` 撞「数据库已关闭」。改为生成器内自建连接 |
| 2 | **大屏页图表全白** | `/dashboard` 无尾斜杠,`src="vendor/echarts.min.js"` 被解析成 `/vendor/…` → 404 |
| 3 | `/users` 500 | 路由已注册但 `users.html` 不存在 |
| 4 | 明细页日期筛选失效 | 视图传 `f.frm`、模板读 `f.from`;导出链接拼 `?frm=` 而接口只认 `from` |
| 5 | 配置页 3 个维护按钮全死 | 模板调 `WBU.bindMaint()`,`app.js` 里没有该函数 |
| 6 | 审计只能看最近 40 条 | `LIMIT 40` 写死 |
| 7 | 明细页多跑一条无用 `SELECT` | `day_list()` 取了没人用 |
| 8 | 登录页锁定阈值写死 | 未从配置注入 |
> **#2 值得单独一提**:前两层测试都没抓到(离线断言只看状态码 + `<html>`,
> 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。
> 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。
另外在容器实测中发现并修掉一个**部署期才暴露**的问题:
| 症状 | 根因 | 处理 |
|---|---|---|
| 容器跑着跑着页面全 500,日志里 `sqlite3.OperationalError: unable to open database file` | 数据原本用**绑定挂载**;Windows + Docker Desktop 走 **9p**,宿主的 Windows 进程只要访问过这个 WAL 库(**纯读也会触发**),容器侧下一次连接就重建不了 `-shm`,且**不会自愈** | 改为 **Docker 命名卷**(容器独占数据目录);需要宿主目录时叠加 `docker-compose.hostdir.yml`(仅建议 Linux) |
最小复现:容器正常 → 宿主跑一次 `manage.py stats` → 容器立刻打不开库、且重启前不再恢复。
已写进 [DEPLOYMENT.md 第十一节](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### 安全
- 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、
`/\evil.com`、绝对地址与含 CR/LF 的值
- 缺 CSRF 的写请求统一 400(此前部分路径漏检)
- 默认**开启**云端 HTTPS 证书校验(`ssl_verify=1`)。Cookie 是账号凭证,不该裸奔
- 登录失败计数表加上限(4096 个 IP)与 TTL(1 小时),防内存被大量来源 IP 撑爆
- `/logout` 拆分为 POST(执行)+ GET(仅提示),防 `<img src="/logout">` 静默退出
- `settings` 的内部簿记键(`slot:*`)读写两侧都过滤,不再从 `/api/settings` 泄漏
### 内部质量
- 设置项**写时校验 + 读时兜底**:`config.normalize_setting()`(范围 + 单位,非法值 400
并列出全部错误)+ 采集路径全面改用 `db.get_int()`,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 `ValueError` → 400 处理器:手写 query string 不再能把 500 页面暴露出去
- 日期归一化 `norm_day()` / `norm_window()`(容忍 `2026/09/08`、带时间、起止写反)
- CSV 导出改用 `csv.writer` 流式写入(此前手工拼字符串,`model`/`client` 含逗号会串列)
- `bundle` 明细加下限(`BUNDLE_RECORDS_CAP = 20000`),并返回
`recordsTotal` / `recordsCap` / `recordsTruncated`,不静默丢数据
- 轮询日志尾部改用 `collections.deque(maxlen=n)`,不再把整个文件读进内存
- 修掉一条非法 CSS 声明 `font: 13px/1.5 inherit`(简写里 `inherit` 不能当字族,
整条被浏览器丢弃,输入框一直用默认字体)
### 工具
- 新增 `tools/smoke.py`:**离线回归 99 项断言**(全页面只读渲染 + 模板残留 +
历史缺陷防回归 ①~⑭ + 静态资源逐个 200 + CSV 列 + 页面 class ↔ `app.css` 选择器对账),
不需要先起服务
- `tools/check_live.py` 扩充到 **56 项断言**,新增用户管理 / 审计 / 流式导出 /
开放重定向 / CSRF 四节
- 新增 `tools/shots.py`:Playwright 登录后逐页截图 + 收集 console / pageerror
(内建可执行文件探测,规避驱动与本机浏览器版本错位)
---
## [1.0.0] — 2026-09-14
**主题:从「脚本 + CSV + 静态大屏」演化为独立可部署的门户**
### 新增
- 独立 Flask 应用:`create_app` 工厂 + `views` / `api` 双蓝图
- SQLite(WAL)作为**唯一数据正本**,主键去重、断点续采、聚合全部下推 SQL
- 进程内调度线程(20 秒轮询 + 槽位去重 + 启动补跑),**不再依赖外部计划任务**
- 单写者文件锁 `data/collect.lock`(含 30 分钟僵尸锁抢占)
- 登录鉴权(`pbkdf2:sha256:200000`)、全站 CSRF、同 IP 失败限速
- 后台页面:概览 / 数据明细 / 任务管理 / 配置管理 / 日志管理
- 独立 ECharts 交互大屏 `/dashboard`(离线自带 echarts,不依赖 CDN)
- CLI:`init` `serve` `collect` `migrate-csv` `import-xlsx` `import-creds`
`fill-prompt` `export-csv` `stats` `status` `passwd` `vacuum`
- 从旧版 CSV / 官网 xlsx / 编辑器设置导入的迁移通道
- 从 VSCode / Cursor / Trae 的 `settings.json` 接管 Cookie 与 User-Agent
### 变更
- 数据正本从 CSV 改为 SQLite;CSV 降级为导出物(`data/exports/`)
- 采集从外部脚本改入 Web 进程;删除全部外部自动化与计划任务
- 运行期配置(Cookie、调度、采集参数)从文件搬进数据库,由后台页面维护
---
## 版本对照
| 版本 | 数据正本 | 调度 | 部署 | 鉴权 |
|---|---|---|---|---|
| 1.1.0 | SQLite(WAL) | 进程内 | Docker Compose / 裸机 | 登录 + CSRF + 角色 |
| 1.0.0 | SQLite(WAL) | 进程内 | 裸机 | 登录 + CSRF |
| < 1.0 | CSV 文件 | 外部计划任务 | 脚本 | 无(仅局域网) |