文件
workbuddy-portal/docs/CHANGELOG.md
T
wangchuanli df7db3582e feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- 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 同步
2026-09-15 17:32:35 +08:00

15 KiB
原始文件 Blame 文件历史

变更日志

本项目遵循 语义化版本:主版本.次版本.修订号。

  • 主版本:不兼容的变更(数据库迁移需手工介入、接口契约变化)
  • 次版本:向后兼容的功能新增
  • 修订号:向后兼容的缺陷修复

[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/: 用户使用手册(含 9 张界面截图,全部用合成示例数据渲染)、 部署与运维指南(含推镜像到 Gitea 注册表的完整流程)、 架构与设计说明、 接口参考、 常见问题
  • 开源声明体系(仓库根目录):LICENSE(MIT)、 THIRD-PARTY-NOTICES.md(依赖清单与再分发合规自查)、 CONTRIBUTING.md(含「必须遵守的不变量」与五层自检方法)、 SECURITY.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 第九节。

安全

  • 新增 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 文件 外部计划任务 脚本 无(仅局域网)