2 次代码提交
作者 SHA1 备注 提交日期
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
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
共修改 57 个文件,包含 6610 行新增和 1454 行删除
+9 -1
查看文件
@@ -18,6 +18,14 @@ WB_ADMIN_PASSWORD=
# 一个容器一份调度。只有跑多副本时才把除第一份之外的都设成 1。
WB_DISABLE_SCHEDULER=0
# ---------- 会话安全 ----------
# 会话 Cookie 是否只允许走 HTTPS。
# 0 = 关闭(默认,纯 HTTP / 局域网部署的正确值)
# 1 = 只在 HTTPS 下发送。**如果你用 http:// 访问却设成 1,会出现
# 「登录成功又立刻跳回登录页」**,因为浏览器根本不会回传会话 Cookie。
# 只有在前面挂了 HTTPS 反向代理、并且用域名访问时才设为 1。
WB_COOKIE_SECURE=0
# ---------- 可选:启动时自动导入 ----------
# 1 = 尝试从挂载进来的编辑器 settings.json 读取 codebuddyUsage.* 写入数据库
WB_IMPORT_CREDS=0
@@ -26,7 +34,7 @@ WB_IMPORT_XLSX=
# ---------- 镜像名(推送 Gitea 注册表时用)----------
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:latest
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
# WB_IMAGE=git.iwali.top/wangchuanli/workbuddy-portal:1.2.0
# ---------- 仅叠加 docker-compose.hostdir.yml 时有效 ----------
# 把数据/日志放到宿主机目录而不是命名卷。**只建议 Linux 宿主机使用**:
+16
查看文件
@@ -20,6 +20,11 @@ data/**/instance.json
data/exports/*.csv
# 兜底:手工 cp 出来的快照(如 usage.sqlite.bak-pre-v13)绝不能入库,
# 它含 settings 的凭证密文与 users 的密码哈希
data/*.bak*
data/*.sqlite-bak*
# 界面截图(tools/shots.py 生成的临时产物;手册配图在 docs/images/)
data/shots/
@@ -29,6 +34,17 @@ data/demo/
# ---- 日志 ----
logs/*
# ---- 数据库备份 ----
# 全目录忽略,只放行说明文件。备份含凭证密文与密码哈希,永不入库。
# 刻意不放在 data/:data/ 是 Docker 卷,down -v 会把备份和正本一起删掉。
backups/*
!backups/README.md
!backups/.gitkeep
# ---- v1.0 旧版归档 ----
# 若仓库被整体移到工作区根,legacy-v1/(含真实 CSV)必须继续被忽略
legacy-v1/
# ---- 保留目录本身 ----
# docker-compose 是绑定挂载,宿主机目录必须先存在,
# 否则 Docker 会以 root 自动创建,Linux 上会引发「unable to open database file」
+56 -8
查看文件
@@ -28,31 +28,40 @@ python manage.py serve --port 8848 # 或 docker compose up -d --
## 二、用示例数据开发,别用真实数据
`tools/demo_data.py` 会生成一份**完全合成**的数据集(假模型名、假 Prompt、偏斜的积分分布),
放在 `data/demo/` 下(该目录已在 `.gitignore` 内):
`tools/demo_data.py` 会生成一份**完全合成**的数据集(假模型名、假 Prompt、假 Cookie 串、
偏斜的积分分布),放在 `data/demo/` 下(该目录已在 `.gitignore` 内)。
它会造**两个账号**(`admin` 管理员 + `demo` 普通用户),好让你顺手验证多用户隔离:
```bash
python tools/demo_data.py # 默认 data/demo,管理员 admin/admin123
python tools/demo_data.py # 默认 data/demo;两个账号口令都是 admin123
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
```
> 目录里的 `usage.sqlite` **在容器里生成**再截图才是对的:宿主机跑会让启动日志印出
> `C:\Users\<用户名>\…`,那一行正好会出现在「日志管理」页的截图上。
这样你既能有一个「看起来像真的」的界面来调试,也不会把任何真实用量带进仓库或截图。
## 三、改动前请先跑一遍验证
项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层:
项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层;
**只要动过 `*.md`,第 1.5 层必须跑**:
| # | 命令 | 覆盖什么 | 需要什么 |
|---|---|---|---|
| 1 | `python -m compileall -q workbuddy_portal manage.py tools` | 语法 | — |
| 2 | `python tools/smoke.py` | **99 项**离线断言:全页面只读渲染、模板残留检测、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848` | **56 项**真实 HTTP 断言,含登录/CSRF/开放重定向 | 一个运行中的服务 |
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror` | Playwright + Chromium |
| 1.5 | `python tools/check_docs.py` | **文档自检**:内部链接与跨文件锚点、图片引用、**绝对路径泄漏**、版本一致性、产品名硬编码。改过任何 md 都跑,否则章节重排造成的**锚点静默失效**会一路漏到线上。文档清单自动发现,不写死文件名 | — |
| 2 | `python tools/smoke.py` | **215 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、**非管理员越权面全关死**、**全局键必须落在实例级**、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) |
| 3 | `python tools/check_live.py --base http://127.0.0.1:8848 --as demo:admin123` | **122 项**真实 HTTP 断言,含登录/CSRF/开放重定向/验证码/安全响应头,`--as` 追加普通账号越权验收 | 一个运行中的服务 |
| 4 | `python tools/shots.py --base http://127.0.0.1:8849` | 登录后逐页截图并收集 `console`/`pageerror`,**含普通账号只读视角**与越权面探测 | Playwright + Chromium |
| 5 | `docker compose up -d --build && docker compose ps` | 容器化路径 | Docker |
> `tools/shots.py` 是**最有价值的一层**:项目曾经出过「大屏整页全白」的 bug,
> 只有它抓到了(`smoke` 与 `check_live` 都放过了)。改前端务必跑。
`check_live.py` 与 `shots.py` 都接受 `--db <路径>`:给了之后它们会直接从库里读验证码答案,
从而**自动过掉登录页与注册页的验证码**——不然脚本会被验证码挡在门外。
这三支脚本都会打印 `RESULT: ok=N fail=0`,`fail` 不为 0 时退出码是 1,可直接接进 CI。
## 四、必须遵守的几条不变量
@@ -72,11 +81,50 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
6. **`.gitignore` 不支持行尾注释**——规则后跟 `# 注释` 会让整行失效。注释必须单独占一行,
改完用 `git check-ignore -v <file>` 逐条确认命中。
### 多用户与权限相关的六条(v1.2.0 起,v1.3.0 扩充)
7. **`uid` 必须是 `conn` 之后的第一个位置参数,且不给默认值。**
这是防越权的核心机制:漏传就直接 `TypeError`,而不是静默返回所有人的数据。
`query.*` / `collect.*` 全链路都遵循它。新写一个查询函数时请照做,**不要**加 `uid=0` 这种默认值。
8. **凭证不参与回落。** `db.NO_FALLBACK_KEYS = {"cookie", "user_agent"}`:
个人级没有值时**不许**落回实例级,否则等于拿别人的 Cookie 去采集(串号)。
新增任何「账号身份相关」的配置键,都要考虑是否该进这个集合。
9. **验证码答案只能放服务端。** 不要图省事塞进 `session`——Flask 的 session 是
「签名 + base64」而非加密,客户端能直接解开读到答案。下发给浏览器的只有随机 `captcha_id`;
且校验时**先删后判**(一次性)。同理,验证码图不要用 SVG 渲染,那玩意是文本。
10. **写权限只有一个入口:`config.writable_by(key, is_admin)`。**(v1.3.0)
页面上的置灰 / 隐藏只是「不给误导性按钮」,真正的闸门是服务端判断 ——
所以别在模板里另写一套「哪些键只读」的条件,那必然会和接口判断漂移。
新增一个可配置项时,先决定它的归属:`GLOBAL_KEYS`(实例级、仅管理员)
还是 `USER_EDITABLE_KEYS`(个人级、人人可写本人那份),然后只改这一处。
11. **「键存哪一级」和「谁能写」必须对齐。**(v1.3.0)
反例:把采集参数只写进管理员自己的 `user_id`,其它账号读取时会回落到 `DEFAULTS`,
于是**管理员改的值对别人完全不生效** —— 不报错、不进日志,是个纯粹的静默 bug。
所以实例级策略(调度、采集参数、注册策略)一律存 `user_id=0`,
并由 `db.set_setting()` 强制重定向(`slot:*` 这类**个人簿记键**除外,它们本来就该是个人级)。
12. **`user_id=0` 不是孤儿行。**(v1.3.0)
实例级配置挂在一个不对应任何真实账号的 `user_id=0` 上。任何「清理孤儿行」的语句
都必须排除它 —— `DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)`
会把整片实例级配置删掉(历史缺陷:实测把 19 个实例级键清到只剩 1 行)。
现在 `tools/smoke.py` 里有「跑完整轮 smoke 后实例级配置一条不少」的防回归断言,别删。
13. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。**
`ALTER TABLE … RENAME TO` 会**把索引一起带走**,后续 `CREATE INDEX IF NOT EXISTS`
就变成空操作,新表会零索引。本项目在迁移前先调 `_drop_all_user_indexes()`,
并把 `ALTER TABLE … ADD COLUMN` 放在 `executescript` 之前。
### 加密与验证码这两块(零依赖约束)
14. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」
—— 所以 ChaCha20、HMAC、PNG 编码、点阵字模都是手写的。想引 `cryptography` 或 `Pillow`
之前先想清楚:这会让「下载即跑」的卖点消失。
15. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀
原样返回(兼容历史明文),但**校验不过就抛 `DecryptError`**。静默降级会让加密形同虚设。
## 五、代码风格
- 遵循 PEP 8;行宽 100。
- **注释与文档字符串用中文**,说明「为什么这么做」而不是「这行在做什么」。
- 提交前用 `python -m compileall` 与 `python tools/smoke.py` 自查。
- 提交前用 `python -m compileall` 与 `python tools/smoke.py` 自查;**动过文档再加一条 `python tools/check_docs.py`**。
- 不要引入新的第三方依赖,除非有充分理由并在 PR 里说明——这个项目的卖点之一就是依赖少。
如果确实新增了,请同步登记到 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。
+1 -1
查看文件
@@ -26,7 +26,7 @@ FROM python:3.13-slim AS runtime
LABEL org.opencontainers.image.title="WorkBuddy Portal" \
org.opencontainers.image.description="WorkBuddy 积分用量采集 / 存储 / 呈现一体化门户" \
org.opencontainers.image.version="1.1.0" \
org.opencontainers.image.version="1.3.0" \
org.opencontainers.image.source="https://git.iwali.top/wangchuanli/workbuddy-portal"
ENV PYTHONUNBUFFERED=1 \
+230 -103
查看文件
@@ -11,13 +11,16 @@
| 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 |
| 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` |
| 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) |
| 部署 | Docker Compose / 裸机 `waitress`;镜像可推 Gitea 容器注册表 |
| 鉴权 | 全站登录 + CSRF + 角色(管理员 / 普通用户),凭证存库、页面只回掩码 |
| 版本 | v1.1.0 |
| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 |
| 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) |
| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理都归管理员 |
| 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 |
| 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 |
| 版本 | v1.3.0 |
| **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) |
**目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) ·
[页面一览](#页面一览) · [接口一览](#接口一览) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可)
[页面一览](#页面一览) · [接口一览](#接口一览) · [目录结构](#目录结构) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可)
---
@@ -25,12 +28,17 @@
| 能力 | 说明 |
|---|---|
| **多用户隔离** | 每个账号只填**自己的** Cookie、收**自己的**数据、看**自己的**记录。`user_id` 是所有查询的第一个条件,且是**必填位置参数**(漏传直接 `TypeError`,不会静默返回全量) |
| **两级权限** | 注册出来的账号一律是**普通账号**,能改的只有本人凭证(`cookie` / `user_agent`)。写权限只有一条规则:`config.writable_by(key, is_admin)` —— 页面与接口共用同一个函数,避免「界面置灰但接口还能写」这类规则漂移 |
| **配置作用域对齐** | 「键存在哪一级」与「谁能改」是一件事:实例级键(调度、采集参数、注册策略…)一律存 `user_id=0` 且仅管理员可写。**不会出现「管理员改了只有自己生效」**(那样别人读时会回落到默认值,是个静默 bug) |
| **凭证加密** | Cookie 以 ChaCha20(RFC 8439)+ HMAC-SHA256 encrypt-then-MAC 密文入库;主密钥单独放在 `data/instance.json`,与 `SECRET_KEY` 分开。升级时会把历史明文自动加密 |
| **自助注册 + 验证码** | 开放注册(可关),登录/注册均带**图形验证码**。答案是服务端本地点阵渲染的 PNG,只存库、一次性、5 分钟过期——**不进会话**(Flask 会话是签名不加密的,放进去等于送答案) |
| **增量采集** | 按 `MAX(ts)` 断点续采 + 回退窗口;主键 `ON CONFLICT` 去重,冲突时以「更早的本地时间」为准 |
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
| **进程内调度** | 每天固定时刻(默认 `09:00,17:00`)由内置线程逐个启用账号触发;支持**启动补跑**(程序没开时错过的时刻,开机后在宽限期内补上) |
| **单写者保证** | 文件锁 `data/collect.lock` 让「调度 / 页面手动触发 / CLI」三处不并发写 SQLite;僵尸锁 30 分钟可抢占 |
| **全量存档** | 不随官网导出窗口过期而丢数据;官网 xlsx 丢失约 22% 的 `Prompt`,可用 `fill-prompt` 回补 |
| **大屏去中间层** | 大屏直接走 `/api`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计 |
| **可观测** | 每次采集落一条 `collect_runs`(含 `[warn]`/`[error]` 逐行原文);另有操作审计与登录审计,均带账号归属 |
| **一键备份** | 正本就是宿主机上的一个 `.sqlite` 文件,拷走即可;`manage.py vacuum` 回收空闲页 |
---
@@ -40,75 +48,117 @@
```
┌──────────────── workbuddy-portal(单进程)────────────────┐
云端用量接口 │ │
/billing/meter/ │ scheduler.py ──┐ │
/billing/meter/ │ scheduler.py ──┐ 按实例级时刻表遍历启用账号 │
get-user-request- │ (20s 轮询槽位) │ │
usage │ ▼ │
▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │
│ │ ▲ data/usage.sqlite(WAL) │
│ │ ▲ (全部带 user_id) data/usage.sqlite(WAL)│
└───────────┼──────┘ ▲ │
client.py(urllib)│ │ │
│ query.py(聚合全部下推 SQL) │
client.py(urllib)│ ▲ crypto.py 解密本账号 Cookie │ │
│ └ captcha.py 出验证码图 │ │
│ query.py(uid 为第一个查询条件) │
│ ▲ ▲ │
│ web/views.py ──────┘ └──── web/api.py│
│ (Jinja 后台) (JSON) │
└───────────────┬───────────────────────────┬──────────────┘
▼ ▼
/ /records /tasks /dashboard(ECharts 大屏)
/config /logs /users
/ /records /tasks /config /dashboard(ECharts 大屏)
/profile [/logs /users:仅管理员] + 未登录:/login /register
```
四层职责:
五层职责:
| 层 | 位置 | 说明 |
|---|---|---|
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出 |
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者,运行期配置也在库里(`settings` 表) |
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL |
| 采集 | `workbuddy_portal/collect.py` + `scheduler.py` | 纯 `urllib` 调云端;断点、去重、锁、导入导出,全部按 `uid` 隔离 |
| 存储 | `workbuddy_portal/db.py` + `schema.sql` | SQLite WAL,单写者;运行期配置也在库里(`settings` 表,主键 `(user_id, key)`) |
| 加固 | `workbuddy_portal/crypto.py` + `captcha.py` | 凭证静态加密(零第三方依赖手写 ChaCha20);验证码用**自写 PNG 编码器**出图 |
| 聚合 | `workbuddy_portal/query.py` | `daily / dims / top / records / summary / bundle`,全部下推 SQL,`uid` 是第一个条件 |
| 呈现 | `workbuddy_portal/web/` | Jinja 后台(`views.py`)+ JSON API(`api.py`)+ 静态大屏 |
> **为什么验证码不用 SVG、也不用第三方库**:SVG 是文本,答案会明文出现在页面源码里;
> 而本项目坚持 `requirements.txt` 只有 Flask / waitress / openpyxl,所以 PNG 编码器
> (zlib 压缩 IDAT)与 5×7 点阵字模都是手写的,见 [架构说明](docs/ARCHITECTURE.md#凭证加密与验证码)。
---
## 快速开始
### 方式一:Docker Compose(推荐)
三条路径产出的是**同一份代码、同一个数据库格式**,可以互相迁移。完整参数与排错见
[部署与运维指南](docs/DEPLOYMENT.md)。
### 路径 A:裸机 Python
```bash
pip install -r requirements.txt
python manage.py init --user admin --password '一个足够强的密码'
python manage.py import-creds # 可选:把编辑器设置里的 cookie/UA 接管进数据库
python manage.py migrate-csv # 可选:把旧版 CSV 存档全量导入
python manage.py serve # 启动,默认 0.0.0.0:8848
```
### 路径 B:Docker 自打包
```bash
git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
cp .env.example .env # 至少设好 WB_ADMIN_PASSWORD
# 编辑 .env: WB_ADMIN_PASSWORD=一个足够强的密码
docker compose up -d --build
docker compose logs -f # Ctrl-C 退出日志跟踪,容器继续跑
```
打开 `http://<本机IP>:8848` → 用 `.env` 里设的账号登录 → 去「配置管理」粘贴 Cookie。
### 路径 C:compose 拉云端镜像(不需要 clone 仓库)
在任意空目录写一个 `docker-compose.yml`(内容见
[部署指南第五节](docs/DEPLOYMENT.md#五路径-cdocker-compose-拉云端镜像部署))+ `.env`,然后:
```bash
docker login git.iwali.top -u <用户名> # 密码填 Access Token(公开仓库可跳过)
docker compose pull
docker compose up -d
```
三条路径跑起来后都是:打开 `http://<IP>:8848` → 用管理员登录 →
**先把密码改掉** → 去「配置管理」粘贴 Cookie。
> 数据与日志放在 Docker **命名卷**(`workbuddy-portal_wb_data` / `_wb_logs`)里,
> `docker compose down` 不会删。要用 CLI 就 `docker compose exec portal python manage.py …`。
> **不要在宿主机上跑 `manage.py` 去连容器的库**——Windows + Docker Desktop 的 9p 挂载下,
> 宿主进程碰一次 WAL 库就会让容器打不开数据库(纯读也会触发,且不自愈)。
> 想直接看到数据/日志,用 `docker-compose.hostdir.yml` 叠加层(**仅建议 Linux 宿主机**)。
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
> 详见 [部署与运维指南](docs/DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### 方式二:裸机 Python
> **从旧版本升级不需要手工介入**:`init` 由 `PRAGMA user_version` 驱动,自动迁移且幂等。
> v1.1.0 → v1.2.0 是「单用户 → 多用户」(历史数据归到首个账号、明文 Cookie 就地加密);
> v1.2.0 → v1.3.0 是「配置作用域收敛」(调度与采集参数从个人级提升到实例级)。
> 两次都带审计留痕,可重复执行。见 [CHANGELOG](docs/CHANGELOG.md)。
```bash
pip install -r requirements.txt
### 第一次使用必做
python manage.py init # 建表 + 默认配置 + 管理员 admin/admin123
python manage.py import-creds # 可选:把编辑器设置里的 cookie/UA 接管进数据库
python manage.py migrate-csv # 可选:把旧版 CSV 存档全量导入
python manage.py serve # 启动,默认 0.0.0.0:8848
```
**管理员:**
### 第一次使用必做三件事
1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。
2. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效
(这是**实例级**的,对全站账号生效)。
3. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。
1. **改密码**——局域网可访问,默认密码等于没锁门(「配置管理 → 修改密码」)。
2. **填 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `cookie_expired`。
获取方式见 [用户手册](docs/USER-GUIDE.md#三获取并填写-cookie)。
3. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效。
**普通账号(注册进来的默认身份):**
1. **改密码**——「个人中心 → 修改登录密码」。
2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。
3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。
### 想给同事开账号?
登录页底部有「**自助注册**」入口(管理员可在「配置管理 → 实例级设置」关掉)。
注册同样要过验证码,且同一来源每天最多注册 3 个账号(可改)。
**注册出来的都是普通账号**:只能维护自己的 Cookie、只看自己的数据,看不到日志与用户管理。
每个账号登录后填**自己的** Cookie——系统不会、也无法把某人的凭证给别人用。
> 只想内部开号、不开放注册?管理员在「用户管理」页直接新建即可;
> 命令行也行:`python manage.py passwd alice 强密码`(默认普通账号,加 `--role admin` 提权)。
---
@@ -116,82 +166,104 @@ python manage.py serve # 启动,默认 0.0.0.0:8848
统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。
**所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定;
不指定则取「管理员优先、其次 id 最小」的那个(所以旧习惯的单账号用法仍然成立)。
唯独 `collect` 不带 `-u` 时会**逐个启用账号**跑一遍,与进程内调度线程的行为一致。
| 命令 | 作用 |
|---|---|
| `init` | 初始化数据库(幂等)。`--user` / `--password` 指定首个管理员 |
| `init` | 初始化 / 迁移数据库(幂等)。`--user` / `--password` 指定首个管理员 |
| `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` |
| `collect` | 执行一次增量采集后退出(不想开 Web 时可挂系统计划任务) |
| `migrate-csv [文件]` | 从旧版 CSV 存档导入(默认自动探测旧项目路径) |
| `import-xlsx <文件>` | 合入官网「用量明细-导出」的 xlsx |
| `import-creds` | 从 VSCode / Cursor / Trae 的 `settings.json` 读取 `codebuddyUsage.*` 写入数据库 |
| `fill-prompt` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
| `export-csv [路径]` | 导出与官网 xlsx 同构的 CSV(默认 `data/exports/`) |
| `collect [-u 账号]` | 执行一次增量采集后退出;**不带 `-u` 则所有启用账号各跑一次** |
| `migrate-csv [文件] [-u 账号]` | 从旧版 CSV 存档导入(默认自动探测 `legacy-v1/data/usage_records.csv` 等路径),必须说明「算谁的」 |
| `import-xlsx <文件> [-u 账号]` | 合入官网「用量明细-导出」的 xlsx |
| `import-creds [-u 账号]` | 从 VSCode / Cursor / Trae 的 `settings.json` 读取 `codebuddyUsage.*` 写入该账号 |
| `fill-prompt [-u 账号]` | 回补缺失的 `User Prompt`(官网导出会丢约 22%) |
| `export-csv [路径] [-u 账号]` | 导出 CSV(默认 `data/exports/usage_records_<账号>.csv`,文件名带归属) |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收空闲页、压缩 WAL |
| `stats` | 存档概况 + 模型维度表 + 最近采集(不联网) |
| `status` | 调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
| `passwd <用户> [新密码]` | 重置或创建登录账号 |
| `stats [-u 账号]` | 先全库概览(每账号多少条 / 多少积分 / Cookie 状态),再给指定账号的维度明细 |
| `users` | 列出所有账号:角色、状态、数据量、凭证状态、最近登录 IP |
| `status` | 逐账号显示调度开关 / 下次执行 / Cookie 状态 / 最近采集 |
| `passwd <用户> [新密码]` | 重置或创建账号;`--role admin` 提权,`--activate` 顺手启用 |
### 自检工具
| 脚本 | 层 | 说明 |
|---|---|---|
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**99 项断言**(历史缺陷防回归 ①~⑭、CSV 列、class↔CSS 对账、静态资源逐个 200),**不需要先起服务** |
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项),**56 项断言**,基本只读 |
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,产物在 `data/shots/` |
| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**215 项断言**:历史缺陷防回归、多用户隔离 / 凭证保密 / 注册与验证码全链路、**非管理员越权面全关死**、**全局键必须落在实例级**、CSV 列、class↔CSS 对账、静态资源逐个 200。**不需要先起服务** |
| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项 → 验证码与响应头),**122 项断言**,基本只读。`--as 账号:密码` 追加普通账号越权验收 |
| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,**含普通账号只读视角**与越权面探测,产物在 `data/shots/` |
| `tools/check_docs.py` | 文档自检 | 内部链接与**跨文件锚点**、图片引用、**绝对路径泄漏**、版本一致性(`__init__` / `Dockerfile` / `README` / `CHANGELOG` 四处)、模板与 JS 里的产品名硬编码。文档互相引用后章节一重排,锚点会**静默失效**,这层把它变成可执行断言 |
| `tools/demo_data.py` | 示例数据 | 生成**完全合成**的示例库(管理员 + 普通账号各一份数据与假 Cookie),文档截图基于它 |
```bash
python tools/smoke.py # 离线,随时可跑
python tools/check_docs.py # 文档自检,有问题退出码 1
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
python tools/check_live.py --base http://127.0.0.1:8849 --as demo:admin123
python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图(含普通账号视角)
```
> `smoke.py` 会写少量 `audit_log` 审计行(被拒的配置写入也留痕),不动业务数据;
> `check_live.py` 只读,但登录成功会更新 `users.last_login_at` / `login_count`。
> `check_live.py` 的 `--db` **默认指向 `data/usage.sqlite`**。对示例实例跑时必须显式传
> `--db data/demo/usage.sqlite`,否则验证码答案从真实库取,会以「登录失败」的形式误导排查。
> `smoke.py` **会对实例级配置做写入测试**(管理员写 `schedule_times` 后还原),
> 并**临时**建两个普通账号用于验证权限边界与注册链路(无论成败都在 `finally` 里删掉),
> 不动任何用量数据;跑之前建议先备份,或在示例库上跑。
> `check_live.py` / `shots.py` 只读,但登录成功会更新 `users.last_login_at` / `login_count`。
> 两者在验证码策略为 `always` 时会**从本地库里取答案**以完成自动登录
> ——取的是会话里的 captcha id(答案本身只存在于服务端)。
---
## 页面一览
| 路径 | 作用 |
|---|---|
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态、模型 TOP、最近采集 |
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 |
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV |
| `/tasks` | **任务管理**:调度开关与时刻、启动补跑、按区间补采、运行历史 |
| `/config` | **配置管理**:Cookie / UA、采集参数、TLS 校验、修改密码、维护动作(回补 Prompt / 导出 / 整理库) |
| `/logs` | **日志管理**:逐次采集详情(含 `[warn]`/`[error]` 原文)、状态筛选、应用日志、操作审计 |
| `/users` | **用户管理**(仅管理员):新建账号、改显示名/权限/密码、删除、用户操作审计 |
| 路径 | 作用 | 普通账号 |
|---|---|---|
| `/login` | **登录**(未登录时的落点):用户名 / 密码 / **图形验证码**,底部有自助注册入口 | ✅ |
| `/register` | **自助注册**:用户名、显示名、邮箱、密码 + 验证码 | ✅(可被管理员关闭) |
| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态(只读)、模型 TOP、最近采集 | ✅ 只看自己的 |
| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 | ✅ |
| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | ✅ |
| `/tasks` | **任务管理**:手动采集与按区间补采、运行历史。**调度开关与时刻对普通账号是只读的** | ⚠️ 只读调度 |
| `/config` | **配置管理**:自己的 Cookie / UA(**唯一可改**)+ 只读的采集参数 + 维护动作;底部实例级设置区 | ⚠️ 仅凭证可改 |
| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 | ✅ |
| `/logs` | **日志管理**(**仅管理员**):全实例采集详情(含 `[warn]`/`[error]` 原文)、应用日志尾部、操作审计 | ❌ 403 |
| `/users` | **用户管理**(**仅管理员**):新建账号、改显示名/权限/状态/密码、删除、账号操作审计 | ❌ 403 |
![概览](docs/images/01-overview.png)
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md)。
> 其余页面截图见 [用户手册](docs/USER-GUIDE.md);普通账号的只读视角见
> `docs/images/03b-tasks-user.png` 与 `04b-config-user.png`。
---
## 接口一览
全部需要登录(`/api/*` 未登录返回 `401` JSON);写接口另需 CSRF(请求头 `X-CSRF-Token`,
页面已注入 `window.WB_CSRF`)。完整参数说明见 [docs/API.md](docs/API.md)。
页面已注入 `window.WB_CSRF`)。
**所有数据接口都只返回当前登录账号的数据** —— `user_id` 由会话决定,不接受客户端传入。
完整参数说明见 [docs/API.md](docs/API.md)。
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态 |
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` |
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) |
| GET | `/api/daily` | 逐日聚合(含每日分模型、24 时段) |
| GET | `/api/dims` | 模型 / 客户端 / 时段汇总 |
| GET | `/api/top` | 单笔消耗榜(唯一带 Prompt 摘要的接口) |
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情 |
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) |
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集 |
| GET | `/api/audit` | 操作审计分页 + 可选动作清单 |
| POST | `/api/collect` | 手动触发采集(可指定区间补采) |
| POST | `/api/maintenance/<action>` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount` |
| GET/POST | `/api/settings` | 读 / 写配置(非法值 `400` 并列出全部错误) |
| POST | `/api/password` | 修改自己的登录密码 |
| GET/POST | `/api/users` · `/api/users/<id>` | 用户管理(仅管理员) |
| GET | `/logs/tail` · `/records/export` | 应用日志尾部 / 按筛选流式导出 CSV |
| 方法 | 路径 | 作用 | 权限 |
|---|---|---|---|
| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态(含 `cookieChars`/`cookieBroken`/`role`) | 登录 |
| GET | `/api/bundle` | 大屏一次取齐:全量 `daily` + 窗口 `dims`/`top`/`records`/`totals` | 登录 |
| GET | `/api/summary` | KPI + 环比(前一段不在存档内则不给假数字) | 登录 |
| GET | `/api/daily` · `/api/dims` · `/api/top` | 逐日聚合 / 模型·客户端·时段汇总 / 单笔消耗榜 | 登录 |
| GET | `/api/records` · `/api/records/<id>` | 明细分页 / 单条详情(`<id>` 也受 `user_id` 约束) | 登录 |
| GET | `/api/runs` · `/api/runs/<id>` | 采集运行历史 / 单次详情(含逐行日志) | 登录 |
| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集;含 `is_admin` / `can_edit_schedule` / `can_view_logs` | 登录 |
| GET | `/api/audit` | 操作审计分页 + 可选动作清单 | 管理员看全站,普通账号看自己 |
| POST | `/api/collect` | 手动触发采集(可指定区间补采);未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken` | 登录(只采自己) |
| POST | `/api/maintenance/<action>` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount` | `vacuum` 仅管理员 |
| GET/POST | `/api/settings` | 读 / 写配置。读只回**掩码** `cookie_hint`,并回 `_globalKeys` / `_userKeys` / `_role` / `_canEditGlobal`;写非本人可写的键会整单拒绝(`400` + `denied` 清单) | 登录;写仅本人凭证或管理员 |
| POST | `/api/profile` · `/api/password` | 改自己的显示名 / 邮箱 / 密码 | 登录 |
| POST | `/api/captcha` | 验证码机制自述(策略、位数、TTL、图片地址),便于排障自检 | 登录 |
| GET/POST | `/api/users` · `/api/users/<id>` · `/api/users/<id>/delete` | 用户管理 | **仅管理员** |
| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 |
| GET | `/logs/tail` | 应用日志尾部 | **仅管理员** |
| GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) |
---
@@ -200,7 +272,7 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图
```
workbuddy-portal/
├── manage.py 统一 CLI(唯一入口)
├── requirements.txt
├── requirements.txt 仅 Flask / waitress / openpyxl(零多余依赖)
├── Dockerfile 多阶段构建(依赖层 / 运行层)
├── docker-compose.yml 单服务编排(数据放 Docker 命名卷)
├── docker-compose.hostdir.yml 可选叠加层:改用宿主机目录(仅建议 Linux)
@@ -208,25 +280,33 @@ workbuddy-portal/
├── docker/
│ ├── entrypoint.sh 幂等初始化 → exec serve(LF 行尾)
│ └── healthcheck.py 标准库健康检查(免登录页 /login)
├── docs/ 文档(见下)
├── docs/ 文档 + images/(手册配图,合成数据)
├── data/ 【运行时】正本 usage.sqlite / instance.json / exports/ / demo/
├── logs/ 【运行时】app.log(滚动 2 MB × 3)
├── backups/ 数据库快照统一落点(整目录被 git 忽略)
├── tools/
│ ├── smoke.py 离线回归(99 项断言)
│ ├── check_live.py 真实 HTTP 验收(56 项断言)
│ └── shots.py Playwright 逐页截图 + JS 报错收集
│ ├── smoke.py 离线回归(215 项断言)
│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号)
│ ├── shots.py Playwright 逐页截图 + JS 报错收集
│ ├── demo_data.py 生成合成示例库(管理员 + 普通账号)
│ └── push-all.sh 一条命令推代码 + 推镜像到 Gitea
└── workbuddy_portal/
├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度
├── config.py 路径、项目标识、默认值、写时校验
├── db.py SQLite 连接、schema、settings 读写、审计
├── schema.sql 表结构
├── security.py 密码哈希、session、CSRF、失败限速、safe_next、角色
├── config.py 路径、项目标识、默认值、**配置作用域与写权限**、密钥管理
├── db.py SQLite 连接、schema、按作用域读写配置、账号、审计
├── schema.sql 表结构(多用户布局)
├── crypto.py 凭证静态加密(手写 ChaCha20 + HMAC-SHA256)
├── captcha.py 图形验证码(手写 PNG 编码器 + 点阵字模)
├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头
├── client.py 云端接口(urllib)+ 编辑器凭证读取
├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出
├── scheduler.py 进程内调度线程(槽位去重 + 启动补跑)
├── query.py SQL 聚合层
├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑)
├── query.py SQL 聚合层(uid 必填)
└── web/
├── views.py 页面路由
├── views.py 页面路由(含 /login /register /captcha.png /profile)
├── api.py JSON API
├── templates/ base / login / overview / tasks / config / logs / records / users / error
├── templates/ base / login / register / profile / overview / tasks /
│ config / logs / records / users / error
└── static/
├── css/app.css 统一设计令牌
├── js/app.js 带 CSRF 的请求、表单与维护动作绑定
@@ -234,14 +314,20 @@ workbuddy-portal/
└── dashboard/index.html ECharts 大屏(独立页)
```
> `backups/` **刻意不放在 `data/`**:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉——那正好是最需要备份的时刻。
>
> 工作区根下另有一个 `legacy-v1/`(v1.0 单文件版归档,仅作 `migrate-csv` 的来源,可安全删除),
> 它**在仓库之外**,所以含真实数据的 CSV 不会被提交。
---
## 文档导航
| 文档 | 面向 | 内容 |
|---|---|---|
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:登录、各页面操作、Cookie 获取、导出、常见操作 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:Docker、裸机、反向代理、备份恢复、升级回滚、推镜像到 Gitea、排错 |
| [docs/USER-GUIDE.md](docs/USER-GUIDE.md) | **使用者** | 用户使用手册:注册、登录、配 Cookie、各页面操作、导出、**权限与数据边界**、**信息安全与隐私安全**、常见问题 |
| [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) | 运维 | 部署与运维:**三条部署路径(裸机 / Docker 自打包 / compose 拉云端镜像)**、反向代理与 HTTPS、推镜像到 Gitea、备份恢复、升级回滚、巡检、排错、配置项速查 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 开发 | 架构与设计说明:数据模型、调度与锁、聚合边界、安全模型、设计取舍 |
| [docs/API.md](docs/API.md) | 开发 / 集成 | 接口参考:路径、参数、返回结构、错误码 |
| [docs/FAQ.md](docs/FAQ.md) | 所有人 | 常见问题:采集为空、Cookie 失效、时区、性能、权限 |
@@ -258,13 +344,39 @@ workbuddy-portal/
局域网可访问 ⇒ 以下每一条都必要:
- **必须改默认密码**;给只读同事发普通账号(`is_admin=0`),不要共用管理员。
- **Cookie 就是账号凭证**:只以掩码回显,存库不外传;默认开启 TLS 证书校验(`ssl_verify=1`),
仅在自签 / 企业代理场景临时关闭。
- **必须改默认密码**;给同事发**普通账号**(注册出来的默认身份),不要共用管理员
——管理员是能停用别人账号的角色。
- **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。
普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份);
调度频率、采集参数、注册策略、日志、用户管理全部关死。
**页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与
`writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。
- **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量);
`/api/runs/<id>`、`/api/records/<id>` 这类按 id 取的单条接口也带 `user_id` 约束;
`/logs`、`/logs/tail`、`/users`、`/api/users*` 仅管理员。
- **Cookie 静态加密**:ChaCha20 + HMAC-SHA256(encrypt-then-MAC)密文入库,主密钥在
`data/instance.json` 的 `cookie_key`(**与 `SECRET_KEY` 分开**,轮换代价不同)。
`get_settings()` 把加密键一律置空,要明文只有 `db.get_secret()` 一条路——
这样任何「顺手打印全部配置」的代码都带不出凭证。升级时历史明文会被自动加密。
- **Cookie 不跨账号回落**:`NO_FALLBACK_KEYS`(`cookie` / `user_agent`)不参与实例级回落,
否则新账号会「继承」管理员的凭证,属于最严重的串号越权。
- **实例级也不存凭证**:`init_db()` 灌默认值时会跳过 `USER_EDITABLE_KEYS` 并显式删除
实例级的 `cookie` / `user_agent` 行——实例级存凭证等于给所有账号发同一张身份。
- **验证码先于口令校验**:登录时先验验证码再比密码,避免攻击者拿「密码对不对」当信号,
在解验证码之前就把字典跑完。答案存服务端 `captchas` 表,**一次性、5 分钟过期、按用途隔离**,
下发到浏览器的只有随机 id(Flask 会话是签名不加密的,放答案等于送答案)。
- **注册受双重限制**:验证码 + 同 IP 每日配额(默认 3 个,可改;`allow_register=0` 可整体关闭)。
- **停用账号立即失效**:`current_user()` 每个请求回查 `users.status`,不必等 12 小时会话过期。
- **CSRF 全站校验**,退出登录也是 `POST`(GET 型退出能被 `<img src="/logout">` 静默触发)。
前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)——
disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。
- **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。
- **登录限速**:同 IP 连续失败 5 次锁定 10 分钟;失败计数表有上限与 TTL。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key`)、`data/usage.sqlite`、`logs/`、`.env`(含明文密码)。
- **登录限速**:按 **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次即锁 10 分钟;
失败计数表有上限与 TTL;验证码出图另有 60 秒 40 张的限速(不设限就是一条廉价的 CPU 放大路径)。
- **安全响应头**:CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、
`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `Cache-Control: no-store`。
- **不进版本库的文件**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、
`data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`、`logs/`、`.env`(含明文密码)。
---
@@ -285,8 +397,8 @@ Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在
### 参与贡献
- 想改代码?先读 [CONTRIBUTING.md](CONTRIBUTING.md) —— 里面有**必须遵守的几条不变量**
(SQLite 单写者、列表接口不回 `prompt` 全文、两种字段命名契约不要互相「统一」……),
以及从 `compileall` 到容器验证的五层自检该怎么跑。
(SQLite 单写者、列表接口不回 `prompt` 全文、两种字段命名契约不要互相「统一」、
写权限只能走 `config.writable_by`……),以及从 `compileall` 到容器验证的五层自检该怎么跑。
- 有想法但手上没有真实数据?`python tools/demo_data.py` 会生成一份**完全合成**的示例库,
写到 `data/demo/`(已在 `.gitignore` 内),可直接拿来调试界面与截图。
- 发现安全漏洞?**请不要开公开 Issue**,按 [SECURITY.md](SECURITY.md) 走私有渠道。
@@ -296,8 +408,23 @@ Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在
`docs/images/` 的全部界面截图与 `docs/` 中的 JSON 示例**均为合成数据**:
模型名统一为 `demo-*`,客户端为 `vscode` / `webconsole` / `sdk`,Prompt 为通用示例文本,
Cookie 是 `deadbeef…` / `cafef00d…` 这类一眼可辨的假串,
审计 IP 取自 RFC 5737 的文档专用网段(`192.0.2.0/24`)。
生成方式是 `tools/demo_data.py`,所以任何人不需要真实账号就能复现整套文档。
生成方式是 `tools/demo_data.py`(会造 `admin` 与 `demo` 两个账号,各有自己的数据),
所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(**13 张**,
含注册页、个人中心,以及**普通账号视角的只读「任务管理 / 配置管理」**),
脚本会读示例库里的验证码答案自动过掉登录。
> ⚠️ **重出截图时务必用相对路径起示例服务**:
> ```bash
> cd workbuddy-portal
> WB_DATA_DIR=data/demo WB_LOG_DIR=data/demo/logs python manage.py serve --port 8849 --no-scheduler
> ```
> 用绝对路径(包括 Git Bash 里的 `$PWD`,它展开成 `/c/...`)会让启动日志印出
> `C:\Users\<用户名>\…`,而那一行正好会出现在「日志管理」页的截图上。
> 另外 Git Bash 下 `$PWD` 是 MSYS 风格路径,Python 会把它解析成 `C:\c\Users\…`
> —— 于是**服务连的是另一个空库**,界面上看不出异常,但截图数据全不对
> (症状:验证码取不到答案,`shots.py` 报「需要验证码但取不到答案」)。
### 致谢
+98 -18
查看文件
@@ -6,12 +6,18 @@
| 版本 | 是否接受安全修复 |
|---|---|
| `1.1.x`(当前) | ✅ |
| `< 1.1` | ❌ 请先升级 |
| `1.3.x`(当前) | ✅ |
| `< 1.3` | ⚠️ 可用,但建议升级(1.3.0 收紧了普通账号的越权面,见下) |
| `< 1.2` | ❌ 请先升级(1.2.0 修掉了单用户时代「Cookie 明文入库」与「人人都是管理员」两个根本问题) |
> **1.3.0 修掉了什么**:此前「调度时刻 / 采集参数」是**个人级**配置,普通账号可以
> 自己改(等于让普通账号决定这台服务器怎么发请求、关不关 TLS 校验);
> 且 `/logs` 对普通账号开放。现在这两块都收归管理员,普通账号只保留
> 「维护本人 Cookie / User-Agent」这一项写权限。
## 如何报告漏洞
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key`、可复现的绕过步骤)。
**请不要在公开 Issue 里贴出可直接利用的细节**(含真实 Cookie、`secret_key` / `cookie_key`、可复现的绕过步骤)。
请通过以下任一私有渠道联系维护者:
@@ -32,36 +38,110 @@
理解这些边界,有助于你判断某个现象是「设计如此」还是「真的漏洞」:
### 身份、会话与权限
| 项 | 做法 | 位置 |
|---|---|---|
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `workbuddy_portal/security.py`、`web/views.py` |
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截 | `security.check_csrf` |
| 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` |
| 角色(两档) | 管理员 / 普通。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum` | `security.admin_required` |
| **写权限只有一条规则** | `config.writable_by(key, is_admin)` —— 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人那份)。页面与接口共用这一个判断,杜绝「界面置灰但接口还能写」 | `config.writable_by` |
| **界面置灰 ≠ 安全边界** | 页面上的 disabled / hidden 只是「不给误导性按钮」;真正的闸门是服务端 `@admin_required` 与 `writable_by()`,所以直接敲 URL 或构造请求也过不去 | `web/views.py`、`web/api.py` |
| 越权写整单拒绝 | 一次提交里只要含不可写的键,整个请求 `400` + `denied` 点名,不做「部分生效」 | `web/api.py:api_settings_post` |
| 停用即失效 | `current_user()` **每个请求**回查 `users.status`,不等 12 小时会话过期 | `security.current_user` |
| 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` |
| CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` |
| 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` |
| 口令存储 | 加盐哈希,不存明文 | `security.hash_password` |
| 凭据脱敏 | 云端 **Cookie 是账号凭证**:入库后页面与接口**都不回传全文**,只给「N 字符,结尾 …xxxx」 | `config.SECRET_KEYS`、`web/views.py` |
| 开放重定向 | 登录后的 `next` 只允许站内相对路径 | `web/views.py` |
| TLS 校验 | 默认开启,**不提供「关掉校验」的快捷开关**(Cookie 不该裸奔) | `settings.ssl_verify` |
| 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` |
| 口令存储 | 加盐哈希(PBKDF2-SHA256),不存明文;强度校验(≥8 位、含两类字符、不得等于用户名) | `security.hash_password` / `password_problem` |
| 开放重定向 | 登录后的 `next` 只允许站内相对路径,`//evil.com` 一律回落到 `/` | `security.safe_next` |
| 失败限速 | **IP 与用户名两个维度**分别计数,任一维度连续失败 5 次锁 10 分钟;计数表有上限与 TTL | `security.auth_locked` / `note_auth_fail` |
| 响应头 | CSP(`frame-ancestors 'none'`)、`X-Frame-Options: DENY`、`nosniff`、`Referrer-Policy: same-origin`、COOP;`/api/*` 与 `/captcha*` 带 `no-store` | `security.apply_security_headers` |
### 多用户数据隔离
| 项 | 做法 | 位置 |
|---|---|---|
| 强隔离 | `uid` 是 `conn` 之后的**第一个位置参数且无默认值**;漏传直接 `TypeError`,不会退化成「返回全量」 | `query.py` / `collect.py` / `scheduler.py` |
| 按 id 取单条也隔离 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` | `web/api.py` |
| **配置作用域与写权限对齐** | 「键存在哪一级」与「谁能改」是一件事:`GLOBAL_KEYS` 一律存**实例级 `user_id=0`** 且仅管理员可写;`USER_EDITABLE_KEYS` 是个人级且人人可写本人那份。`set_setting()` 强制把全局键重定向到 `user_id=0`,从结构上消除「管理员改了只有自己生效」 | `config.GLOBAL_KEYS` / `config.USER_EDITABLE_KEYS`、`db.set_setting` |
| 三级回落 | `个人 → 实例(user_id=0) → DEFAULTS` | `db.get_settings` |
| 凭证不回落 | `NO_FALLBACK_KEYS = {cookie, user_agent}` **不参与实例级回落** —— 回落等于新账号继承管理员凭证,是最严重的串号越权 | `db.get_setting` |
| 实例级也不存凭证 | `init_db()` 灌默认值时跳过 `USER_EDITABLE_KEYS`,并显式 `DELETE FROM settings WHERE user_id=0 AND key IN ('cookie','user_agent')` —— 实例级存凭证等于给所有账号发同一张身份 | `db.init_db` |
| 实例级不是孤儿 | `user_id=0` 不对应任何真实账号,**任何「清理孤儿行」的语句都必须排除它**(历史缺陷:`DELETE ... WHERE user_id NOT IN (SELECT id FROM users)` 曾把实例级配置整片删掉) | `tools/smoke.py` 防回归断言 |
| 日志隔离 | `/logs`、`/logs/tail` **仅管理员**;操作审计对普通账号只下发本人记录 | `web/views.py`、`web/api.py` |
| 导出不互相覆盖 | `/records/export` 与 CLI `export-csv` 的文件名带账号名 | `web/views.py`、`collect.export_csv` |
| 前端不误提交只读项 | `app.js:formData()` 跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端按「越权修改只读项」拒掉**整单** | `web/static/js/app.js` |
### 云端凭证(Cookie)的保密
| 项 | 做法 | 位置 |
|---|---|---|
| **静态加密** | ChaCha20(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,密文 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`;手写实现,零第三方依赖 | `workbuddy_portal/crypto.py` |
| 密钥分离 | 主密钥 `cookie_key` 与 `SECRET_KEY` **分开键位**存放(两者轮换代价不同:换 `cookie_key` 会让所有已存 Cookie 失效) | `config.encryption_key` / `secret_key` |
| 唯一明文出口 | `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把 `ENCRYPTED_KEYS` 一律置空,所以「顺手回传全部配置」的代码带不出凭证 | `db.py` |
| 只回掩码 | 页面与 `/api/settings` 只给「N 字符,结尾 …xxxx」与 `broken` 标志,`secret_state()` 不返回明文 | `db.secret_state` |
| 失败即报错 | `decrypt()` 校验失败**抛 `DecryptError`**,绝不「失败就返回原值」;非 `v1.` 前缀视为历史明文原样返回(下次写入自动升级) | `crypto.decrypt` |
| 历史明文清理 | 启动迁移时把 settings 里残留的明文凭证就地加密,并写一条 `encrypt_secrets` 审计 | `db._encrypt_legacy_secrets` |
| TLS 校验 | 默认开启;`ssl_verify` 是**实例级且仅管理员可改** —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 | `settings.ssl_verify`、`config.GLOBAL_KEYS` |
### 防自动化攻击
| 项 | 做法 | 位置 |
|---|---|---|
| 图形验证码 | 手写 PNG 编码器 + 5×7 点阵字模 + 干扰线/噪点;**不用 SVG**(SVG 是文本,答案会明文出现在页面源码里) | `workbuddy_portal/captcha.py` |
| 答案不进会话 | 答案只写服务端 `captchas` 表;会话里仅存随机 id —— Flask 会话是「签名不加密」的,放答案等于送答案 | `security.issue_captcha` |
| 一次性 | 校验后立即删除,且**先删后判**;5 分钟过期、按 `purpose` 隔离,不能拿注册的题去登登录 | `captcha.verify` |
| 先验码后验密 | 登录先校验验证码再比对口令,避免攻击者拿「密码对不对」当提前信号跑完字典 | `web/views.py` |
| 出图限速 | 每来源 60 秒最多 40 张(不设限就是一条廉价的 CPU/带宽放大路径) | `security.captcha_fetch_allowed` |
| 注册配额 | 同 IP 每日最多注册 N 个(默认 3,可改);`allow_register=0` 可整体关闭 | `security.register_quota` |
### 其它
| 项 | 做法 | 位置 |
|---|---|---|
| 容器权限 | 运行层非 root(uid/gid 1000 `app`) | `Dockerfile` |
| 上传体量 | `MAX_CONTENT_LENGTH = 4 MiB` | `workbuddy_portal/__init__.py` |
| 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` |
**绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、
`data/shots/`(截图里可能有真实账号信息)、`data/demo/`、`backups/`(库快照 = 凭证密文 + 密码哈希)、
`logs/*`、`.env`。
**绝不入库**:`data/instance.json`(含 `secret_key`)、`data/usage.sqlite`、`logs/*`、`.env`。
`.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v <file>` 逐条复核。
注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。
> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷,
> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。
## 已知的**非**目标(部署方需自行处理)
本项目刻意不做下面这些,请按你的环境补齐:
- **没有多租户与细粒度权限**:登录用户即为管理员,能改配置、能看全部数据。
- **没有限流与账号锁定**:公网暴露前请置于反向代理的 rate limit 之后。
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。
- **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时
**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。
注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。
- **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。
- **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。
- **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。
- **不建议直接暴露到公网**:设计前提是局域网或 VPN 内使用。
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制。请勿把该 Cookie 分享给他人,
它的权限等同于你的账号;轮换后记得在「配置管理」页更新。
- **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。
它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。
- **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。
- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志,
理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 ——
界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。
## 部署前的最小检查清单
- [ ] 已修改默认管理员口令(`WB_ADMIN_PASSWORD`),不再是 `admin123`
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写
- [ ] 前面有反向代理并启用了 HTTPS
- [ ] 已确认是否要开放自助注册;开放时按需调小 `register_max_per_ip`
- [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥)
- [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1`
- [ ] 确认 `data/instance.json` 没有被提交到任何仓库
- [ ] 已规划备份(SQLite 库是唯一正本)
- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.sqlite`)
- [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待
- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人才给管理员
- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是
「已保存但无法解密」
- [ ] 升级到 1.3.0 后确认「任务管理」里调度时刻**对普通账号是只读的**
(用普通账号点一次「保存」应被拒并点名越权项)
+91
查看文件
@@ -0,0 +1,91 @@
# backups/ — 数据库备份存放处
人工或脚本产生的数据库快照统一放这里。**不要放进 `data/`**,原因见下。
## 为什么备份不放在 `data/`
`data/` 是运行时数据目录,在 Docker 部署下会被挂载成卷(`workbuddy-portal_wb_data`)。
- 备份放进 `data/` → 执行 `docker compose down -v` 或重建卷时,**备份会跟着正本一起被删掉**,
这正好是最需要备份的那一刻。
- 备份放在仓库目录下的 `backups/` → 卷重建不影响它,同时离源码够近、搬家不丢。
`backups/` 下的一切都被 `.gitignore` 忽略(见仓库根 `.gitignore` 的「数据库备份」段),
**绝不要提交**:快照里含 `settings` 表的加密凭证密文与 `users` 表的密码哈希。
## 目录内容
| 文件 | 说明 |
| --- | --- |
| `usage.sqlite.bak-pre-v13` | v1.2.0 → v1.3.0 迁移(`DB_SCHEMA_VERSION` 2 → 3)前的快照。保留用意:迁移同时做了「配置作用域收敛」(把调度与采集参数从个人级提升到实例级 `user_id=0`),万一收敛结果不符合预期,可回滚到这份 uv=2 的库重来。 |
### 校验记录(2026-09-16)
```
integrity_check : ok
user_version : 2
usage_records : 1665 条
SUM(credits) : 8513.36
```
迁移后正本 `data/usage.sqlite`(uv=3)同样是 **1665 条 / 8513.36 积分**,零丢失。
> **快照本身是自包含的**:全部数据都在主文件里(`-wal` 为 0 字节,没有未落盘的提交帧)。
>
> 但要注意一个会反复出现的现象:**只要有人以 WAL 模式打开过这份快照,
> SQLite 就会就地重建 `…-wal` / `…-shm` 两个侧车文件** —— 连只读打开也会
> (SQLite 需要 `-shm` 做锁表)。所以「移走一次」不是长久之计,
> 校验命令里加 `immutable=1` 才是根治(告诉 SQLite 这个文件不会变,不必建锁表)。
>
> 侧车只是运行时缓存,`backups/` 已在 `.gitignore` 里被整体忽略,
> 不会误入库。归档或搬运快照前把两个侧车移走即可,前提是先确认 `-wal` 是 0 字节。
## 怎么用
**校验一份备份是否可用**(`immutable=1` = 只读且不建锁表,**不会改动也不会污染快照**):
```bash
python -c "
import sqlite3
c = sqlite3.connect('file:backups/usage.sqlite.bak-pre-v13?immutable=1', uri=True)
print('integrity:', c.execute('PRAGMA integrity_check').fetchone()[0])
print('user_version:', c.execute('PRAGMA user_version').fetchone()[0])
print('records:', c.execute('SELECT COUNT(*) FROM usage_records').fetchone()[0])
"
```
> 备份文件的**唯一权威判据**是 `integrity_check` 与行数,别拿 `sha256` 当验签 ——
> 快照落盘后再被 SQLite 干净关闭过一次,主文件字节可能变化而内容完全一致。
> 校验只需 `sqlite3` 标准库,用你跑服务的**同一个**解释器即可。
**回滚一份备份**:停掉服务 → 备份当前正本 → 把快照覆盖回 `data/usage.sqlite`
→ **同时删除 `data/usage.sqlite-wal` 与 `data/usage.sqlite-shm`**(残留的 WAL 会让 SQLite 读到旧状态)
→ 启动服务 → 跑 `python manage.py status` 与 `python manage.py stats` 确认账号与存档条数。
> 回滚前务必确认目标库的 `user_version` 与服务端 `workbuddy_portal/db.py` 的
> `DB_SCHEMA_VERSION` 兼容:回滚到更老的版本号时,服务会在下次启动时重跑迁移。
## 自动备份(可选)
容器部署下推荐用宿主机的 cron 做,**先落盘再压缩**,避免 SQLite 在线拷贝产生撕裂快照:
```bash
# 每天 03:30,用 SQLite 自带的一致性备份命令(不是 cp!)
docker compose exec -T portal python -c "
import sqlite3
src = sqlite3.connect('/app/data/usage.sqlite')
dst = sqlite3.connect('/tmp/wb-backup.sqlite')
src.backup(dst); dst.close(); src.close()
"
docker compose cp portal:/tmp/wb-backup.sqlite \
"./backups/usage-$(date +%Y%m%d).sqlite"
docker compose exec -T portal rm -f /tmp/wb-backup.sqlite
```
`cp` 一个正在被写入的 SQLite 文件可能拿到半截事务;`sqlite3.Connection.backup()`
走的是官方的在线备份 API,能保证快照一致。手工离线拷贝时才可以直接 `cp`。
## 清理策略
保留最近 7 份日备 + 每份迁移前快照。旧的直接删,别在这里堆积——
`backups/` 是安全网,不是归档;数据正本永远只有 `data/usage.sqlite` 一个。
+3
查看文件
@@ -37,6 +37,9 @@ services:
WB_ADMIN_USER: ${WB_ADMIN_USER:-admin}
WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-}
WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0}
# 会话 Cookie 是否只走 HTTPS。纯 HTTP 部署必须留 0:设成 1 时浏览器
# 不会回传会话 Cookie,表现为「登录成功又立刻跳回登录页」。改它要 up -d(环境变量)。
WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0}
WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0}
WB_IMPORT_XLSX: ${WB_IMPORT_XLSX:-}
volumes:
+134 -28
查看文件
@@ -6,8 +6,9 @@
| 项 | 说明 |
|---|---|
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页)。唯一例外是 `/captcha.png` |
| **数据作用域** | **所有数据接口只返回当前登录账号的数据**。`user_id` 由服务端会话决定,**不接受客户端传入** —— 传 `?user_id=1` 会被忽略 |
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400**。未登录的 POST 也会先被 CSRF 拦成 400(这比先鉴权更保守) |
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
@@ -27,6 +28,8 @@
| `forbidden` | 403 | 已登录但权限不足 |
| `not_found` | 404 | 接口或资源不存在 |
| `busy` | 409 | 已有采集在跑(单写者约束) |
| `no_cookie` | 409 | 本账号还没配 Cookie,无法采集 |
| `cookie_broken` | 409 | Cookie 密文解不开(`cookie_key` 换过),需重新粘贴 |
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
| `api` | 502 | 云端接口异常 |
| `internal` | 500 | 服务端异常 |
@@ -211,10 +214,19 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
"scheduler": { "enabled": true, "times": ["09:00","17:00"], "next": "2026-09-14 17:00:00" },
"running_runs": 0,
"last_run": { "id": 8, "trigger": "startup", "status": "ok", "started_at": "...", "message": "..." },
"cookie_set": true
"cookie_set": true,
"is_admin": false,
"can_edit_schedule": false,
"can_view_logs": false
}
```
> 最后三个字段是**前端显隐的依据**(v1.3.0 起)。
> 调度时刻是**实例级**的 —— 普通账号拿到 `can_edit_schedule: false`,
> 页面据此把「采集调度」渲染成只读表格,而不是给一个点了会被拒的表单。
> 大屏是拿不到 Jinja 上下文的静态页,只能靠 `/api/manifest` 的 `role`
> 或这里的字段决定要不要显示「日志管理」入口。
### GET `/api/audit`
操作审计分页。
@@ -235,29 +247,93 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
### GET `/api/settings`
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`slot:*`)不返回。
读**当前登录账号的有效配置**。返回的是一个**扁平字典**:配置键 → 值。
> **凭证只回掩码**:`cookie` 这个键固定为空串,真正的状态在 `cookie_hint` / `cookie_broken`;
> 内部簿记键(`slot:*`)根本不返回。
> 下例中的数字与尾号都是合成示例数据,不是任何真实实例的值。
```json
{
"values": { "page_size": "200", "schedule_times": "09:00,17:00", ... },
"cookie_hint": "4054 字符,…09db9a660825",
"num_settings": { "page_size": [20, 1000, "条/页"], ... },
"bool_settings": ["schedule_enabled", "catch_up"]
"api_base": "https://www.workbuddy.cn",
"api_path": "/billing/meter/get-user-request-usage",
"page_size": "200",
"schedule_times": "09:00,17:00",
"ssl_verify": "1",
"cookie": "",
"cookie_hint": "92 字符,…c0ffee",
"cookie_broken": false,
"user_agent": "Mozilla/5.0 (...)",
"_globalKeys": ["allow_register", "api_base", "api_path", "captcha_length",
"captcha_policy", "catch_up", "catch_up_grace_hours",
"drift_tolerance_minutes", "max_prompt", "page_size",
"register_max_per_ip", "rewind_minutes", "schedule_enabled",
"schedule_times", "ssl_verify", "timeout", "verify_days"],
"_userKeys": ["cookie", "user_agent"],
"_canEditGlobal": true,
"_role": "user"
}
```
| 字段 | 说明 |
|---|---|
| 各配置键 | 有效值(`个人 → 实例 → DEFAULTS` 三级回落后的结果) |
| `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 |
| `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 |
| `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie |
| `_globalKeys` | **实例级**键清单(所有账号共用一份,只有管理员能改)。v1.3.0 起含调度与采集参数 |
| `_userKeys` | **个人级**键清单,即 `config.USER_EDITABLE_KEYS`;普通账号唯一能写的两个键 |
| `_canEditGlobal` | 当前账号能否改 `_globalKeys` 里的键 |
| `_role` | `"admin"` / `"user"` —— 前端据此决定显隐(大屏走 `/api/manifest` 的 `role`) |
> **写权限就一条规则**:`config.writable_by(key, is_admin)`。
> 页面上「哪些输入框可以改」与接口「哪些键能写」用的是同一个函数,
> 所以不会出现「界面置灰但接口还能写」的不一致。
### GET `/api/users`(管理员)
```json
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员", "email": null,
"is_admin": 1, "status": "active", "created_at": "...",
"last_login_at": "...", "last_login_ip": "192.0.2.10", "login_count": 12 } ] }
```
> **口令散列永不出现在响应里。**
> **口令散列永不出现在响应里**(`_user_public()` 只挑安全字段)。
### GET `/logs/tail`
### POST `/api/profile`
应用日志尾部。
改**自己**的显示名与邮箱。
```json
{ "display_name": "新显示名", "email": "me@example.com" }
```
### POST `/api/captcha`
验证码机制的**自述**,便于排障时自检(不需要猜当前策略是什么)。
```json
{ "policy": "always", "length": 4, "ttl_seconds": 300,
"image_url": "/captcha.png",
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。" }
```
### GET `/captcha.png`
**唯一不需要登录的接口**,返回一张 PNG 图形验证码。
| 参数 | 默认 | 说明 |
|---|---|---|
| `purpose` | `login` | `login` \| `register`;其它值一律收敛为 `login`(不会 500) |
- 响应头带 `Cache-Control: no-store`(缓存旧图会导致「图没变但怎么输都错」);
- 每来源 60 秒最多 40 张,超限返回 **429**;
- **答案不会出现在响应里,也不会出现在任何页面源码或会话中** ——
服务端只把随机 id 写进会话(`cap_login` / `cap_register`),答案留在 `captchas` 表。
### GET `/logs/tail`(**仅管理员**)
应用日志尾部。普通账号访问返回 **403**(账号自己的采集日志请用 `/api/runs`)。
| 参数 | 默认 | 说明 |
|---|---|---|
@@ -294,20 +370,25 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|---|---|
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 944 条", ...}}` |
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
| 本账号未配 Cookie | `409 {"ok": false, "error": "no_cookie", "message": "..."}` |
| Cookie 解不开 | `409 {"ok": false, "error": "cookie_broken", "message": "..."}` |
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
> 采集只使用**当前账号自己的** Cookie(`collect.load_credentials(conn, uid)`),
> 且会写一条带 `user_id` 的 `collect_runs`。多用户下不要并发触发采集(单写者约束)。
### POST `/api/maintenance/<action>`
把 CLI 维护动作搬到页面。
| `action` | 作用 |
|---|---|
| `fill-prompt` | 从云端回补缺失的 Prompt |
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
| `recount` | 重新统计并返回当前条数 |
| `action` | 作用 | 权限 |
|---|---|---|
| `fill-prompt` | 从云端回补缺失的 Prompt | 本人 |
| `export-csv` | 全量导出 CSV 到 `data/exports/`(文件名带账号名) | 本人 |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` | **仅管理员** |
| `recount` | 重新统计并返回当前条数 | 本人 |
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
@@ -321,13 +402,24 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|---|---|
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
| 不可写的键(普通账号写实例级) | `400 {"ok": false, "error": "invalid", "errors": ["以下配置仅管理员可修改,本账号无法保存:api_base。普通账号可以维护的是本人凭证(Cookie / User-Agent)。"]}` |
要点:
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
- **写权限判断只有一处**:`config.writable_by(key, is_admin)`。
普通账号能写的**只有** `cookie` 与 `user_agent`(且只限本人这份);
其余(云端接口、注册策略、调度、采集参数)全部仅管理员。
- **越权写是「整单拒绝」而不是「部分生效」**:请求里只要含一个不可写的键,
整个请求 `400`,并在 `errors` 里**点名**是哪些键。这样调用方不会误以为
「既然 `changed` 里没有它就说明写过了」。
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);写 `__clear__` 或 `-` 才是清空;
- `cookie` 落库前会**自动加密**(`db.set_secret`),写进去的永远不是明文;
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
- 内部键(`slot:*`)被忽略;
- 每次拒绝都会写一条 `settings_rejected` 审计。
- 内部键(`slot:*`)被忽略 —— 它们是调度簿记,不属于用户可配置项;
- 改 `schedule_times` 会清掉**已不存在时刻**对应的 `slot:*` 标记(所有账号一起清),
新时刻立即生效。刻意**不做全清**:全清会让所有账号在宽限期内一起重采。
- 每次拒绝都会写一条 `settings_rejected` 审计(管理员的「日志管理 → 操作审计」里能看到,
这也是排查「谁的账号在试越权」的入口)。
### POST `/api/password`
@@ -342,22 +434,36 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
### POST `/api/users`(管理员)
```json
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
{ "username": "viewer", "display_name": "只读同事", "email": "viewer@example.com",
"password": "…", "password2": "…", "is_admin": false }
```
`is_admin` **默认 false**(多用户系统里「默认给管理员」是最常见的越权起点)。
用户名 / 口令强度与自助注册同一套校验。
### POST `/api/users/<id>`(管理员)
```json
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
{ "display_name": "新名字", "email": "…", "is_admin": true,
"status": "active", "password": "可选,重置密码" }
```
**自锁护栏**(服务端强制,全部返回 400):
1. 不能取消自己的管理员身份;
2. 不能停用自己的账号;
3. 不能把最后一个**启用状态的**管理员降权或停用。
### POST `/api/users/<id>/delete`(管理员)
删除账号。**三重护栏**(服务端强制):
删除账号。护栏:
1. 不能取消自己的管理员身份;
2. 不能删除自己;
3. 至少保留一个账号。
1. 不能删除自己;
2. 至少要保留一个账号;
3. 不能删掉最后一个启用状态的管理员。
请求体可选 `{"keep_data": true}` 保留其用量数据;**默认连同数据与 Cookie 一起删除** ——
留下孤儿数据既占空间,也会在有人重新注册同名账号时被看到(`user_id` 复用风险)。
违反返回 `400`。
+214 -24
查看文件
@@ -24,15 +24,17 @@ client.py 纯 urllib 调云端;读编辑器 settings.json 取凭证
│
▼
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
│ ▲
│ │ scheduler.py 只是「到点调 collect.sync()」
│ ▲ 所有函数都要求 uid
│ │ scheduler.py 只是「到点调 collect.sync(conn, uid, ...)」
▼
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置
crypto.py 凭证静态加密(手写 ChaCha20 + HMAC);captcha.py 出验证码图
▼
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置(主键 (user_id,key))
│
▼
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
│
├──▶ web/views.py Jinja 后台(7 个页面)
│ uid 是 WHERE 的第一个条件
├──▶ web/views.py Jinja 后台(含 /login /register /profile + 6 个数据页)
└──▶ web/api.py JSON(大屏 + 页面异步调用)
```
@@ -40,38 +42,69 @@ query.py 全部聚合下推 SQL:daily / dims / top / records / summa
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
口径不可能不一致。
**关键点:没有「当前用户」这种隐式全局。** `uid` 必须由调用方一路显式传下去
(见 [七、安全模型](#七安全模型)),所以「忘了过滤」在类型层面就写不出来。
---
## 二、数据模型
```sql
-- 多用户布局:所有按账号隔离的表都以 user_id 打头
usage_records(
request_id TEXT PRIMARY KEY, -- 云端请求 ID,去重靠它
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
user_id INTEGER NOT NULL DEFAULT 0,
request_id TEXT NOT NULL, -- 云端请求 ID,去重靠它
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
hour INTEGER NOT NULL, -- 派生字段:0-23,供时段分布
model TEXT, client TEXT,
credits REAL NOT NULL,
model TEXT NOT NULL DEFAULT '-', client TEXT NOT NULL DEFAULT '-',
credits REAL NOT NULL DEFAULT 0,
prompt TEXT, -- 可截断(max_prompt)
first_seen TEXT, last_seen TEXT,
cloud_ts TEXT -- 云端原始时间,用于漂移检测
first_seen TEXT NOT NULL, last_seen TEXT NOT NULL,
cloud_ts TEXT, -- 云端原始时间,用于漂移检测
PRIMARY KEY (user_id, request_id) -- 复合主键:去重是「按账号」去重
)
collect_runs(
id INTEGER PRIMARY KEY, trigger TEXT, status TEXT,
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 0,
trigger TEXT, status TEXT,
started_at, finished_at, duration_ms,
win_from, win_to, -- 本次扫描窗口
fetched, added, dup, total, conflicts,
exit_code, message, detail -- detail 存逐行日志原文
)
settings(key PRIMARY KEY, value, updated_at) -- cookie / 调度 / 采集参数 / 与 slot:HH:MM 簿记
users(id, username UNIQUE, password_hash, display_name, is_admin, created_at, last_login_at, login_count)
audit_log(id, at, actor, action, detail, ip)
settings(user_id INTEGER NOT NULL DEFAULT 0, key TEXT NOT NULL, value, updated_at,
PRIMARY KEY (user_id, key))
users(id, username UNIQUE, password_hash, display_name, email,
is_admin, status, register_ip, last_login_ip,
created_at, last_login_at, login_count)
captchas(id TEXT PRIMARY KEY, answer TEXT, purpose TEXT,
created_at, expires_at, used_at)
audit_log(id, user_id, at, actor, action, detail, ip)
```
索引:`day`、`(day,hour)`、`(model,day)`、`(client,day)`、`credits DESC`、`ts`。
覆盖了「按天」「按天+时段」「模型/客户端 × 天」「单笔 TOP」「时间排序」五类热点查询。
**`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示
**实例级**(所有账号共用,例如 `api_base`、`schedule_times`、`page_size`、系统迁移审计);
在 `usage_records` 里是「尚未归属」的兜底值,正常不会出现。
> ⚠️ 实例级**不是**「孤儿行」。清理孤儿数据的 SQL 必须显式排除 `user_id = 0`,
> 例如 `DELETE FROM settings WHERE user_id <> 0 AND user_id NOT IN (SELECT id FROM users)`。
> 写成 `user_id NOT IN (SELECT id FROM users)` 会一次性删光整片实例级配置 ——
> 表现是「所有账号的调度、采集参数、注册策略突然全部回到默认值」。
索引全部以 `user_id` 打头,覆盖六类热点查询:
`(user_id, day)`、`(user_id, day, hour)`、`(user_id, model, day)`、`(user_id, client, day)`、
`(user_id, credits DESC)`、`(user_id, ts)`。
### 为什么要复合主键而不是「加一列 user_id」
`usage_records` 的主键从 `request_id` 变成 `(user_id, request_id)` 是**语义**变化:
两个人可能各自命中同一个云端 `request_id`(同一台机器上的多个浏览器 profile 就会),
单列主键会让后写入的人把前一个人的记录覆盖掉。去重必须按账号做。
### 为什么 `day`/`hour` 是冗余列
@@ -84,6 +117,27 @@ audit_log(id, at, actor, action, detail, ip)
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
用户不会在配置页看到它们,也无法通过接口写入任意键。
多用户下槽位标记是**按账号**的(`(user_id, 'slot:09:00')`),所以谁改了自己的时刻
只影响自己。
### 升级路径:`PRAGMA user_version`
`db.init_db()` 用 `PRAGMA user_version` 判断库结构版本(0/1 = 单用户,2 = 多用户)。
**主键变了的表不能 `ALTER`**,只能重建,顺序不能变:
```
1. 删掉所有自建索引 ← 关键,见下
2. ALTER TABLE ... RENAME TO _v1_xxx
3. ALTER TABLE 加新列(只加列的表用这个,代价小得多)
4. executescript(schema.sql) ← 此时列齐了,表与索引一次建全
5. INSERT ... SELECT 回填(user_id) → DROP TABLE _v1_xxx
6. 把历史明文凭证加密 + 写一条 schema_migrate 审计
```
> **为什么第 1 步不能省**:`ALTER TABLE RENAME` 会把索引**一起带走且名字仍被占用**,
> 于是随后的 `CREATE INDEX IF NOT EXISTS` 被静默跳过 —— 新表一个索引都没有,
> 功能看起来完全正常,查询却慢几百倍。这是最阴的一类迁移 bug。
> 第 3 步放在第 4 步之前,则是为了让引用新列的索引一次就建成功。
---
@@ -240,19 +294,90 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt)
| 密码存储 | `pbkdf2:sha256:200000`(Werkzeug 实现) |
| 会话 | Flask 签名 cookie `workbuddy_portal_sid`,HttpOnly + SameSite=Lax,12 小时 |
| 密钥持久化 | `data/instance.json` 的 `secret_key`,重启不踢人 |
| 失败限速 | 同 IP 连续 5 次失败锁定 10 分钟;计数表有上限(4096 个 IP)与 TTL(1 小时) |
| 失败限速 | **IP 与用户名双维度**各连续 5 次失败锁定 10 分钟;计数表有上限(8192 key)与 TTL(1 小时) |
| 验证码 | 策略 `always`(默认)/ `adaptive` / `off`;**先验码再验密** |
| 注册 | 开关 `allow_register` + 同 IP 每日配额 `register_max_per_ip` + 强制验证码 |
| 停用即失效 | `current_user()` 每请求回查 `users.status`(缓存在 `flask.g`),不等会话过期 |
| 会话固定防护 | `login_session()` 先 `session.clear()`,顺带换掉 CSRF token 与验证码 id |
### 授权
### 授权与数据隔离
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
`@admin_required`(403)两层。`/users` 与 `/api/users*` 全部要管理员。
`@admin_required`(403)两层。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`。
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能删自己、至少留一个账号。
两者之间还有一层「同一个页面、两种形态」:`/tasks` 与 `/config` 对普通账号**仍然可达**,
但渲染成**只读形态**(不给表单,换成只读表格 + 一句说明为什么只读),
写接口也会拒绝。页面形态与接口判断**共用 `config.writable_by()`**,
所以不存在「界面上没按钮、构造请求却能改」的空隙 —— 这是本次权限收敛刻意保证的性质。
**多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计:
```python
# 每个公开函数都把 uid 放在 conn 之后的第一个位置,且**不给默认值**
def daily(conn, uid, frm=None, to=None, with_maps=True): ...
def totals(conn, uid, frm=None, to=None): ...
collect.sync(conn, uid, trigger="manual", ...)
scheduler.slots(conn, uid=0)
```
为什么不给默认值?因为一旦写成 `uid=0`,忘记传参就会**静默返回实例级(≈全量)数据**——
这种越权不会报错、不会进日志,只会在某天被人发现。给成必填参数后,漏传是 `TypeError`,
在第一次跑测试时就炸掉。
配套的几条:
| 项 | 做法 |
|---|---|
| 单条读取也过滤 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` |
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS`(云端接口 + 注册策略 + 调度 + 采集参数)一律存**实例级 `user_id=0`** 且仅管理员可写;普通账号可写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}` |
| **凭证不回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` 跳过实例级回落 —— 否则新账号会「继承」管理员的 Cookie,这是最严重的串号越权 |
| 导出隔离 | CSV 文件名带账号名(多用户下同目录同名会互相覆盖) |
| 审计归属 | `audit_log` / `collect_runs` 都带 `user_id`;`/api/audit` 普通账号只看自己 |
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能停用自己、
不能删自己、至少留一个账号、至少留一个**启用状态的**管理员。
### 凭证加密与验证码
这两件事都要求「零第三方依赖」(`requirements.txt` 只有 Flask / waitress / openpyxl),
所以都是手写标准库实现。
**凭证加密(`crypto.py`)** —— 手写 ChaCha20(RFC 8439 §2.3 的块函数)+ HMAC-SHA256
**encrypt-then-MAC**,所以不存在「先解密再验签」的填充预言类问题:
```
密文 = "v1." + b64(salt) + "." + b64(nonce) + "." + b64(ciphertext) + "." + b64(tag)
子密钥 = HMAC-SHA256(master, salt, "aead-key") / ("aead-mac") ← 密钥分离
```
实现上的三个决定:
1. **`decrypt()` 失败抛异常,绝不返回原值**。返回原值看似「容错」,
实际是把「密钥不匹配」伪装成「Cookie 是个奇怪字符串」,然后把这段垃圾发给云端。
现在会明确抛 `db.SecretUnreadable`,页面提示「密文解不开,请重新粘贴」。
2. **非 `v1.` 前缀原样返回** —— 专门用来兼容单用户时代存下来的明文;
下次写入时自动升级为密文。升级迁移还会主动扫一遍并就地加密。
3. **`get_settings()` 把加密键置空**,明文的唯一出口是 `db.get_secret()`。
这比「记得别回传 cookie」可靠:写新接口的人即使 `**settings` 一把梭也带不出凭证。
**图形验证码(`captcha.py`)** —— 手写 PNG 编码器(zlib 压缩 IDAT)+ 5×7 点阵字模
+ Bresenham 干扰线 + 逐字符抖动 + 噪点:
| 决定 | 原因 |
|---|---|
| 出 PNG 而不是 SVG | SVG 是文本,答案会**明文出现在页面源码里**,等于把答案发给机器人 |
| 不用第三方 captcha/Pillow | 保持零第三方依赖;图像只由点阵矩形构成,没必要引入整个图像栈 |
| 答案不进会话 | Flask 会话是「签名 + base64,**不加密**」的(客户端可解码读明文),放答案等于送答案。只写一个随机 id |
| 先删后判 | `verify()` 先 `DELETE` 再比对,避免并发下同一张图被用两次 |
| 按 `purpose` 隔离 | 拿注册的题去登录校验必然失败 |
| 出图限速 | 60 秒 40 张 —— 出图要做点阵渲染 + zlib 压缩,不设限就是一条廉价的 CPU/带宽放大路径 |
| 登录先验码 | 否则攻击者能拿「密码对不对」当信号,在解验证码之前就把字典跑完 |
### CSRF
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
未登录的 `POST` 也会先被 CSRF 拦成 400(先拦比先鉴权更保守)。
### 开放重定向
@@ -298,6 +423,44 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
### 配置的作用域:三级回落与一个例外
```
个人(user_id=n) ──没有──▶ 实例(user_id=0) ──没有──▶ config.DEFAULTS
▲
└── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级
```
`GLOBAL_KEYS`(共 17 个键,分四组)在读写两侧都被强制折算到 `user_id = 0`,
所以它们天然只有一份,非管理员改不了:
| 分组 | 键 | 为什么归实例级 |
|---|---|---|
| 云端接口(2) | `api_base`、`api_path` | 一台部署连的就是那一个云端,逐账号配没有意义 |
| 注册策略(4) | `allow_register`、`register_max_per_ip`、`captcha_policy`、`captcha_length` | 敞开注册与否是运营决策,不能由任意账号开关 |
| 调度(4) | `schedule_enabled`、`schedule_times`、`catch_up`、`catch_up_grace_hours` | **普通账号不能设置定时任务频率**(本轮需求) |
| 采集参数(7) | `page_size`、`rewind_minutes`、`drift_tolerance_minutes`、`max_prompt`、`verify_days`、`timeout`、`ssl_verify` | 关掉 `ssl_verify` 就能把所有人的 Cookie 发到中间人手里 |
这三样东西必须**对齐**,缺一不可:
1. **键存在哪一级** —— `GLOBAL_KEYS` 一律 `user_id = 0`;
2. **谁能写** —— `config.writable_by(key, is_admin)`:只有 `USER_EDITABLE_KEYS` 对普通账号放行;
3. **读时落哪** —— 三级回落 `个人 → 实例 → DEFAULTS`。
> 🔴 最常见的静默 bug 是「只做到第 2 条」:管理员能改,但值写进了**管理员自己的 `user_id=n`**。
> 别的账号读同一个键时会**落回 `DEFAULTS`**,于是「管理员改了,但只有他自己那边生效」,
> 界面上完全看不出来。所以 `set_setting()` 内部会把全局键**强制重定向**到 `user_id = 0`,
> 从结构上消除这种可能,而不是靠调用方记得传 `0`。
有个 **`slot:*` 例外**:`slot:09:00` 这类簿记键表示「**本账号**今天这个槽位跑过没有」,
它是**个人级**(每个账号各记一份),而它所指向的时刻 `schedule_times` 是**实例级**。
这两者千万别一起改 —— 把 `slot:*` 也搬到实例级,会让两个账号互相以为对方已经跑过当天采集。
有个**容易误判**的细节:`NO_FALLBACK_KEYS` 只拦住「实例级那一行」,不拦 `DEFAULTS`。
所以一个全新账号读 `user_agent` 拿到的是 `DEFAULTS` 里的**通用 Chrome UA**(非空),
而不是空串 —— 这是刻意的(首次采集总得带个 UA)。测试断言要注意:
正确的断言是「新账号的 UA ≠ 实例级那一份」,而不是「新账号的 UA 为空」。
---
## 九、已知坑与红线
@@ -321,6 +484,20 @@ Flask 在 `full_dispatch_request()` 返回 `app_iter` **之后**就 pop 请求
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
### 母模板里的 `{% set %}` 会静默覆盖子模板的同名变量
`base.html` 顶层原本写 `{% set me = current_user() %}`,用来渲染右上角的用户名。
但 `current_user()` 只回 `{id, username, display_name, is_admin}` 四个键 ——
而 `profile.html` 自己也用 `me` 接视图传来的**完整用户行**。
结果:母模板的 `set` 把子模板的 `me` 顶掉了,`me.created_at` 取不到,
个人中心渲染成「账号 admin · 注册于 · 最近登录 未登录」——**不报错、不告警**,
页面看起来只是"少了个时间"。
**规则**:母模板里给全站用的局部变量要**起专门的名字**(现在叫 `cur`),
不要复用子模板可能用到的键。`smoke.py` 里有 3 条断言盯着这件事
(`注册于` 必须是真实日期、`最近登录` 不能是空占位、`base.html` 不得再出现 `set me = `)。
### Jinja 里避开 `dict` 的方法名
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
@@ -367,16 +544,29 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
| 层 | 手段 | 抓什么 |
|---|---|---|
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
| 2 | `tools/smoke.py`(99 项断言,离线) | 模板残留、历史缺陷防回归 ①~⑭、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(56 项断言,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF |
| 2 | `tools/smoke.py`(**215 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、**非管理员越权面全关死**、**全局键必须落在实例级**、静态资源 404、class↔CSS 对账 |
| 3 | `tools/check_live.py`(**122 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头;`--as 账号:密码` 追加普通账号越权验收 |
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
| 附 | `tools/check_docs.py`(**文档层**,不属于上面五层) | 内部链接 / 跨文件锚点 / 图片引用 / **绝对路径泄漏** / 版本一致性 / 产品名硬编码。**章节一重排,锚点就静默失效**,Markdown 自己不报错,只有它抓得到。文档清单自动发现,不写死文件名 |
```bash
python tools/smoke.py # 1~2 层,随时跑
python tools/check_docs.py # 文档层,改过 md 就跑
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
```
关于第 2 层在**多用户**下的两个约定:
1. `login(cli, uid)` 只注入 `uid` 不够「假装」成谁 —— `current_user()` 每请求回查 `users`
表(为的是停用立即失效),所以 `uname` / `adm` 这些会话键改不了权限。
要测非管理员行为,必须**真的**在库里有一个普通账号。
`smoke.py` 会临时建一个(随机用户名,`finally` 里删掉),并在注册链路里临时再建一个。
2. 「验证码答案没泄漏」这类断言要挑对判据:答案是 4 位随机大写串,
直接在页面里搜它只能说明「这次没撞上」。更可靠的是**结构断言** ——
会话里只有 id、库里才有答案、同 id 二次校验必失败、图必须由独立接口下发
(页面里不出现 `data:image`)。
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
+167 -1
查看文件
@@ -8,6 +8,172 @@
---
## [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` · 容器化 · 文档体系**
@@ -81,7 +247,7 @@
| 容器跑着跑着页面全 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#windows-绑定挂载的坑容器打不开数据库)。
已写进 [DEPLOYMENT.md 第十一节](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### 安全
+714 -323
查看文件
文件差异内容过多而无法显示 加载差异
+206 -21
查看文件
@@ -21,6 +21,22 @@ Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8
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
@@ -58,7 +74,7 @@ docker compose exec portal python manage.py stats
```
完整复现步骤与原理见
[部署与运维指南](DEPLOYMENT.md#windows-绑定挂载的坑容器打不开数据库)。
[部署与运维指南](DEPLOYMENT.md#115-windows-绑定挂载的坑容器打不开数据库)。
### Q:`database is locked` / `disk I/O error`
@@ -88,11 +104,27 @@ python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.r
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
填进「配置管理 → 凭证」,保存后按区间补采。
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 条」
大概率正常。看那一次的 `抓取` 条数:
@@ -195,6 +227,39 @@ docker compose exec portal python -c "from workbuddy_portal import db; print(db.
## 五、账号与权限
### 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
@@ -207,20 +272,58 @@ docker compose exec portal python manage.py passwd admin 新密码
不传新密码时会用默认的 `admin123`——**别这么干**。
### Q:怎么给同事开只读账号
账号被停用了要顺便恢复启用,加 `--activate`:
「用户管理 → 新建账号」,**不要勾**「管理员」。
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
```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` 一起备份。
---
## 六、运维
@@ -247,7 +350,7 @@ 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](DEPLOYMENT.md#63-恢复)。
恢复见 [部署指南 8.3](DEPLOYMENT.md#83-恢复)。
**别把新库配旧 WAL 用**——会损坏数据(用上面已 checkpoint 的单文件备份最省心)。
### Q:数据库文件越来越大
@@ -256,27 +359,86 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
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` 只重建容器,不碰卷。
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
加表加索引安全,**改列需要手工迁移**。
但**升级前依然要备份**(见上一条)。
> 迁移用 `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 |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
| 位置 | 内容 | 谁能看 |
|---|---|---|
| `docker compose logs portal` | 容器 stdout(entrypoint + waitress) | 能登服务器的人 |
| 命名卷 `workbuddy-portal_wb_logs` 里的 `app.log` | 应用日志,滚动 2 MB × 3 | 能登服务器的人 |
| 页面「日志管理」 | 全实例采集逐行日志 + 应用日志尾部 + 操作审计 | **仅管理员** |
| 页面「任务管理 → 运行历史」 | **你自己**的采集记录(触发方式、耗时、条数) | 所有登录用户 |
> 普通账号看不到「日志管理」页(导航里不显示,直接敲 `/logs` 返回 403)。
> 自己排错先用「任务管理 → 运行历史」,需要逐行日志时找管理员。
### Q:想改采集的接口地址(走镜像/代理)
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
「配置管理 → 采集参数」里改 `api_base` 与 `api_path`。这两个是**实例级**键,
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
改了之后记得同步确认 Cookie 是该域下的有效凭证。
---
@@ -287,14 +449,37 @@ docker compose exec portal python manage.py vacuum
**五层,前两层必须跑绿**:
```bash
python tools/smoke.py # 离线回归 99 项
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
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 或托管环境里:
+565 -153
查看文件
@@ -1,27 +1,33 @@
# WorkBuddy Portal 用户使用手册
# 用户使用指南
> 面向**使用者**(不是开发者)。读完这份就能独立完成日常操作:
> 登录 → 看用量 → 配置采集 → 查明细 → 导数据 → 处理常见异常。
> 注册 / 登录 → 配好自己的凭证 → 看用量 → 查明细 → 导数据 → 处理常见异常。
>
> 装服务的人看 [部署与运维指南](DEPLOYMENT.md)。**注册进来的账号是普通账号**,
> 权限范围见 [第三章](#三权限与数据边界) —— 这一章请务必读一遍。
> **关于配图**:本文所有截图都用 `tools/demo_data.py` 生成的**合成示例数据**渲染
> ——模型名统一是 `demo-*`,客户端为 `vscode`/`webconsole`/`sdk`,Prompt 是通用示例文本。
> —— 模型名统一是 `demo-*`,客户端为 `vscode`/`webconsole`/`sdk`,Prompt 是通用示例文本,
> Cookie 是**假串**(`wb_demo_session=…`),账号是 `admin` 与 `demo` 两个。
> 所以你可以照着重现出几乎一样的界面,也不必担心文档里夹带真实账号信息。
> 想自己搭一份这样的环境:`python tools/demo_data.py` 然后按输出的提示起服务即可。
**目录**
- [一、这个系统是做什么的](#一这个系统是做什么的)
- [二、登录与账号](#二登录与账号)
- [三、获取并填写 Cookie](#三获取并填写-cookie)
- [四、概览页:一眼看清家底](#四概览页一眼看清家底)
- [五、用量大屏:交互式分析](#五用量大屏交互式分析)
- [六、数据明细页:查、筛、导](#六数据明细页查筛导)
- [七、任务管理页:定时与补采](#七任务管理页定时与补采)
- [八、配置管理页:参数与维护](#八配置管理页参数与维护)
- [九、日志管理页:出问题先看这里](#九日志管理页出问题先看这里)
- [十、用户管理页(仅管理员)](#十用户管理页仅管理员)
- [十一、常见任务速查](#十一常见任务速查)
- [十二、常见问题](#十二常见问题)
- [二、注册与登录](#二注册与登录)
- [三、权限与数据边界](#三权限与数据边界)
- [四、配置你唯一的配置项:Cookie](#四配置你唯一的配置项cookie)
- [五、概览页:一眼看清家底](#五概览页一眼看清家底)
- [六、用量大屏:交互式分析](#六用量大屏交互式分析)
- [七、数据明细页:查、筛、导](#七数据明细页查筛导)
- [八、任务管理页:手动采集与只读的调度](#八任务管理页手动采集与只读的调度)
- [九、配置管理页](#九配置管理页)
- [十、个人中心](#十个人中心)
- [十一、管理员专属功能](#十一管理员专属功能)
- [十二、信息安全与隐私安全](#十二信息安全与隐私安全)
- [十三、常见任务速查](#十三常见任务速查)
- [十四、常见问题](#十四常见问题)
---
@@ -35,17 +41,23 @@
一次典型的日常是:
```
每天 09:00 / 17:00 系统自动采集(你什么都不用做)
管理员把采集时刻统一设好(默认 09:00 / 17:00),服务端到点自动采集
↓
你想看看进度 → 打开「概览」看今天用了多少
想深挖 → 打开「用量大屏」按模型/客户端/时段切
要找某条记录 → 「数据明细」搜索 + 展开 Prompt
要拿给别人 → 「数据明细」→ 导出 CSV
你注册 / 登录 后做的第一件事:粘贴自己的 Cookie(否则采集会跳过你)
↓
想看进度 → 「概览」看今天用了多少
想深挖 → 「用量大屏」按模型 / 客户端 / 时段切
要找某条 → 「数据明细」搜索 + 展开 Prompt
要拿给别人 → 「数据明细」→ 导出 CSV
```
**每个账号只看得见自己的数据**,这一点在下面第三章展开。
---
## 二、登录与账号
## 二、注册与登录
### 2.1 登录
打开 `http://<部署机器IP>:8848`,会看到登录页。
@@ -53,105 +65,219 @@
| 项目 | 说明 |
|---|---|
| 默认账号 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) |
| 默认管理员 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) |
| 登录保持 | 12 小时 |
| 失败限制 | 同一 IP 连续错 5 次,锁定 10 分钟 |
| 验证码 | 默认**始终要求**,4 位,不区分大小写,5 分钟内有效、**只能用一次** |
| 失败限制 | 同一 IP、或同一用户名连续错 5 次,锁定 10 分钟 |
| 退出 | 右上角「退出」(走 POST,防被恶意链接静默触发) |
> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。
> 改法:「配置管理 → 修改密码」,或命令行 `python manage.py passwd admin 新密码`。
**关于验证码**:
### 权限差别
- 图上只有数字与字母,并且**去掉了容易看错的 `0 O 1 I L`**;
- 看不清就**点图片换一张**,不消耗任何额度;
- 一张验证码**用完即废**:输错要换新的,登录用过之后也不能再拿去注册;
- **答案只存在服务器数据库里**,浏览器拿到的只是一个随机编号 —— 在网页源码里搜不到答案;
- 被锁定期间,即使验证码填对也会被拒,等 10 分钟或换一个来源。
| 能力 | 管理员 | 普通用户 |
|---|---|---|
| 看概览 / 大屏 / 明细 / 任务 / 配置 / 日志 | ✅ | ✅ |
| 手动触发采集、补采、改配置 | ✅ | ✅ |
| 导出 CSV | ✅ | ✅ |
| **用户管理**(建号 / 改权限 / 删号) | ✅ | ❌(导航里不显示,直接访问返回 403) |
> ⚠️ 如果你是**管理员**且是首次部署:立刻改密码(`admin123` 等于没锁门)。
> 改法:「个人中心 → 修改登录密码」,或命令行 `python manage.py passwd admin 新密码`。
> 给只读同事发普通账号即可,没必要共用管理员。
### 2.2 自助注册
登录页底部有「**自助注册**」入口(地址是 `/register`)。管理员也可以把这个入口关掉。
![注册页](images/09-register.png)
| 字段 | 要求 |
|---|---|
| 用户名 | 3~32 位,字母或数字开头,可含 `_` `.` `-`;**这是登录名,注册后不可改** |
| 显示名 | 选填,留空则与用户名相同 |
| 邮箱 | 选填,便于日后找回 |
| 密码 | 至少 8 位,且含大写字母 / 小写字母 / 数字 / 符号中的**至少两类** |
| 验证码 | 与登录页同款:5 分钟有效、一次性 |
批量注册被三道闸门挡着:
1. **图形验证码** —— 每次提交都要重新过一遍;
2. **来源限额** —— 同一个 IP 每天最多注册 3 个账号(管理员可调);
3. **总开关** —— 管理员可以随时关闭注册入口。
> ### 注册成功后你是什么权限
> **注册出来的账号一律是「普通账号」**。普通账号能做的事只有一件配置相关的:
> **维护你自己的 Cookie 和 User-Agent**。
>
> 定时任务的频率、采集参数、日志查看这些都属于管理员,你看到的是只读的。
> 详细清单见下一章。
> 注册成功后**不会**自动帮你配好采集。你要粘贴的是**你自己账号**的 Cookie,
> 见 [第四章](#四配置你唯一的配置项cookie)。在那之前,概览页只会提示「未配置凭证」,
> 采集到点时会跳过你并记一条 `no_cookie`。
---
## 三、获取并填写 Cookie
## 三、权限与数据边界
**没有 Cookie,采集一定失败。** 这是首次部署唯一的必要手工步骤。
这是本系统最要紧的一章。**先看清自己能用什么,比急着点按钮有用。**
### 3.1 为什么要 Cookie
### 3.1 两张身份
采集是直接调账号的用量接口,云端用 Cookie 认人。Cookie 是账号凭证,所以它:
- 存在数据库里,页面上**只回显掩码**(如 `a1b2…f9`);
- 不会被任何接口以明文返回。
| | 管理员 | **普通账号(你注册后拿到的)** |
|---|---|---|
| 谁能拿到 | 首个部署账号,或由管理员授权 | 自助注册,或由管理员创建 |
| 配置权限 | 全部 | **只能维护本人的 Cookie / User-Agent** |
| 调度设置 | 可改(实例级,对所有人生效) | **只读**(看不到也改不了频率) |
| 日志查看 | 可看全实例日志与审计 | **无权限**(导航里不显示,直接访问返回 403) |
| 用户管理 | 可建号 / 停用 / 删号 / 改权限 | **无权限**(同上) |
| 看数据 | 只看自己的 | 只看自己的 |
### 3.2 拿 Cookie 的两种办法
### 3.2 你的权限清单
| 能力 | 普通账号 |
|---|---|
| 登录 / 退出 / 改自己的资料与密码 | ✅ |
| **配置本人的 Cookie 与 User-Agent** | ✅ **这是你唯一可改的配置** |
| 查看概览、用量大屏 | ✅(只有你自己的数据) |
| 查看 / 搜索数据明细、展开 Prompt | ✅(只有你自己的记录) |
| 导出 CSV(当前筛选条件) | ✅(只有你自己的记录) |
| 手动「立即采集一次」、按区间补采 | ✅(只采你自己的) |
| 「补全 Prompt」「导出我的 CSV」 | ✅(只动你自己的) |
| 查看采集运行历史 | ✅(只有你自己的) |
| **设置定时任务频率 / 开关 / 补跑策略** | ❌ **只读** |
| **改采集参数**(分页、超时、截断、证书校验…) | ❌ 只读 |
| **查看日志管理页 / 应用日志** | ❌ 403 |
| **改实例级设置**(接口地址、开放注册、验证码策略) | ❌ |
| **用户管理**(建号 / 停用 / 删号 / 改权限) | ❌ 403 |
| **整理数据库**(`VACUUM`,整库操作) | ❌ |
> **为什么调度不给你改**:采集策略是**整机一套**的(一台部署一个调度时刻表,
> 所有账号在同一时刻被采集)。如果每个账号各定时刻,同一分钟里会有多个采集
> 抢同一把写锁 —— SQLite 是单写者,那样只会互相拖慢。
>
> 你需要「马上采一次」的时候,用「任务管理 → 立即采集一次」,随时可用,不受调度限制。
### 3.3 数据边界:你能看到什么
| 是「你的」 | 是「共用的 / 管理员管」 |
|---|---|
| Cookie 与 User-Agent | 接口基址与路径 |
| 用量记录、采集历史、导出的 CSV | 采集调度时刻表与全部采集参数 |
| 个人资料、登录密码 | 是否开放自助注册、注册限额、验证码策略 |
| — | 数据库文件本身、应用日志 |
三条值得记牢:
- **管理员也看不到你的 Cookie。** 它在数据库里是密文,页面上永远只回显
「长度 + 结尾 4 位」,形如 `1238 字符,结尾 …c0ffe`。
- **管理员也看不到你的用量明细内容。** 用户管理页只显示每个账号的
**记录条数 / 积分合计 / 最后登录时间与 IP** —— 只给「有多少」,不给「是什么」。
- **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号),
所以每个账号都必须各自配一次 Cookie。
---
## 四、配置你唯一的配置项:Cookie
**没有 Cookie,采集一定失败。** 这是每个账号**各自**要做一次的手工步骤,
也是普通账号唯一需要动手的配置。
### 4.1 为什么要 Cookie,以及它怎么被保管
采集是直接调账号的用量接口,云端靠 Cookie 认人。Cookie 等于账号凭证,所以系统对它:
- **加密后入库**:落库前用 ChaCha20 + HMAC-SHA256 加密(主密钥在 `data/instance.json`,
与数据库文件分开放),数据库被拷走也读不出明文;
- **永不回传明文**:页面与接口只回显「多少字符、结尾 4 位」;
- **只属于你**:存在你的账号名下,别人(包括管理员)看不到、也拿不到;
- **和 User-Agent 绑在一起**:两者必须取自**同一次浏览器请求**,否则云端会认为是另一个客户端。
> 「配置管理 → 我的云端凭证」里如果出现 **无法解密** 的红字提示,说明实例主密钥被换过
> (`data/instance.json` 被删或被替换),重新粘贴一次即可。详见
> [十四、常见问题](#cookie-显示无法解密)。
### 4.2 拿 Cookie 的两种办法
**办法 A:让程序自己从编辑器设置里读(最省事)**
如果你平时用 VSCode / Cursor / Trae 登录过 WorkBuddy,Cookie 已经在本机设置里:
```bash
python manage.py import-creds
python manage.py import-creds # 不指定 -u 时给「管理员」账号导入
python manage.py import-creds -u alice # 想导给谁就写谁的用户名
```
它会去读编辑器 `settings.json` 里的 `codebuddyUsage.*` 字段,写进数据库。
Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器)。
**办法 B:手工复制(一定可行)**
> ⚠️ 导入的是**运行这条命令的那台机器上、那个编辑器账号**的 Cookie。
> 如果 A 同事的机器上跑这条命令去给 B 同事的账号导入,采到的就是 A 的数据 ——
> 所以**更稳的做法是自己登录网页、粘贴自己的 Cookie**(就是下面的办法 B)。
> 另外这条命令需要能访问部署机器,普通账号通常直接走办法 B。
**办法 B:手工复制(一定可行,推荐)**
1. 浏览器打开并登录 WorkBuddy 官网;
2. 按 `F12` 打开开发者工具 → 切到 **Network(网络)** 标签;
3. 刷新页面,随便点一个发往 `workbuddy.cn` 的请求;
4. 在 **Request Headers(请求标头)** 里找到 `Cookie:` 一行;
5. **整行值**复制下来(很长,通常几千字符,要复制完整);
6. 到本系统「配置管理 → 凭证 → Cookie」,粘贴,保存。
6. 到本系统「**配置管理 → 我的云端凭证**」,粘贴到 `Cookie` 输入框,保存。
> 顺手把 User-Agent 也填成同一个浏览器的 UA,成功率高一些。
### 3.3 验证 Cookie 是否有效
### 4.3 验证 Cookie 是否有效
保存后到「任务管理 → 立即采集一次」,然后看「日志管理」最新一条:
保存后到「**任务管理 → 立即采集一次**」,然后看该页「运行历史」里那一条的记录:
| 日志里看到 | 含义 | 怎么办 |
| 看到 | 含义 | 怎么办 |
|---|---|---|
| `新增 N 条` 或 `无新增(已是最新)` | ✅ 正常 | — |
| `cookie_expired` / `401` / `403` | Cookie 过期了 | 重新执行 3.2 |
| `TLS` / `SSLError` | 证书校验失败 | 见「十二、常见问题」 |
| `cookie_expired` / `401` / `403` | Cookie 过期了 | 重新执行 4.2 |
| `no_cookie`(采集被跳过) | 你**还没配** Cookie | 按 4.2 填一份 |
| `cookie_broken` | 密文解不开(实例主密钥被换过) | 重新粘贴一次,见 [十四](#cookie-显示无法解密) |
| `TLS` / `SSLError` | 证书校验失败 | 找管理员(`ssl_verify` 是实例级参数,你改不了) |
> 普通账号看不到「日志管理」页,但**采集运行历史**在「任务管理」页下方就有 ——
> 排错够用了。整机应用日志和操作审计属于管理员。
---
## 四、概览页:一眼看清家底
## 五、概览页:一眼看清家底
登录后的首页。
![概览页](images/01-overview.png)
从上到下四块:
**1. KPI 卡片(6 个)**
**1. KPI 卡片(6 个)** —— 只统计**归属你本人**的数据,别人的记录不会算进来。
| 卡片 | 含义 |
|---|---|
| 存档总量 | 库里一共多少条记录(只增不减) |
| 累计积分 | 全部记录的积分合计 |
| 存档总量 | 你名下有多少条记录(只增不减) |
| 累计积分 | 你名下全部记录的积分合计 |
| 活跃天数 | 有记录的自然日数量 |
| 今日积分 | 今天(本地时区)已消耗 |
| 今日 vs 昨日 | 今日与**昨日整日**对比,带涨跌幅 |
| 采集健康 | 最近若干次采集的成功 / 失败情况 |
**2. 今日 vs 昨日整日**
注意「昨日」是**完整一天**,而「今日」还在进行中——上午看数字偏低是正常的,
注意「昨日」是**完整一天**,而「今日」还在进行中 —— 上午看数字偏低是正常的,
该跟昨天的**同一时段**比才有意义(大屏页能做这个对比)。
**3. 调度状态**
显示调度开关、下次执行时刻、上次采集结果。这里显示「已停用」时采集不会自动跑,
到「任务管理」把调度打开。
显示调度开关、下次执行时刻、上次采集结果。
**普通账号这里显示的时刻是只读的** —— 它由管理员统一设定,
提示会写「时刻由管理员统一设定,你可以在任务管理里查看,也可以随时手动采集本人数据」。
**4. 模型 TOP + 最近采集**
按模型看积分消耗排行;下方是最近几次采集的触发方式(手动 / 调度 / 启动补跑)、
耗时、抓取条数、新增条数、去重条数。
耗时、抓取条数、新增条数、去重条数。**运行号对普通账号是纯文本**(不能点进日志页)。
> 若你还没配 Cookie,页面顶部会有醒目提示引导你去「配置管理」——
> 在那之前采集到点会跳过你。
---
## 五、用量大屏:交互式分析
## 六、用量大屏:交互式分析
左侧导航点「**用量大屏**」,或直接访问 `/dashboard`。
@@ -166,23 +292,24 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
| 趋势折线 | 按天的积分走势,判断是否在加速 |
| 维度分布 | 按**模型**或**客户端**拆分的占比 |
| 时段分布 | 24 小时里集中在哪些时段(配合判断是否有脚本在跑) |
| 单笔 TOP | 最贵的单次请求,含 Prompt 摘要——最值得优化成本的地方 |
| 单笔 TOP | 最贵的单次请求,含 Prompt 摘要 —— 最值得优化成本的地方 |
![大屏交互](images/08-dashboard-interact.png)
> 页面左上角有「← 返回后台」等入口,随时能回管理后台。
> 大屏的数据是**按当前筛选窗口实时取**的,不是预生成的静态图——切区间会重新请求。
> 页面左上角有「← 返回后台」入口,随时能回管理后台。
> 大屏的数据是**按当前筛选窗口实时取**的,不是预生成的静态图 —— 切区间会重新请求。
> 大屏同样是**按账号隔离**的:你只会看到自己的数据。
**怎么用它省钱**:先看「单笔 TOP」抓出最贵的请求类型,再看「时段分布」判断是不是
某个自动化任务在固定时间跑,最后用「数据明细」把那一批记录导出来逐条分析。
---
## 六、数据明细页:查、筛、导
## 七、数据明细页:查、筛、导
![数据明细页](images/02-records.png)
### 6.1 筛选条件
### 7.1 筛选条件
| 条件 | 说明 |
|---|---|
@@ -196,171 +323,442 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
> 日期写错格式(如 `abc`、`2026-13-99`)不会白屏,系统会忽略非法值并提示。
### 6.2 看单条的完整 Prompt
### 7.2 看单条的完整 Prompt
列表默认**不显示 Prompt 全文**(它占数据体积约 80%)。点行首的「展开」看该条的完整 Prompt。
想批量看就导出 CSV。
### 6.3 导出 CSV
### 7.3 导出 CSV
点「导出 CSV」,会把**当前筛选条件下的全部记录**(不是当前页)流式导出,
文件名形如 `usage_2026-09-01_2026-09-14.csv`。
- 编码为 **UTF-8 带 BOM**,Excel 双击直接打开不乱码;
- 列与官网导出的 xlsx **完全同构**:`requestId, credits, prompt, model, client, requestTime`;
- 数据量大时也是边查边吐,不会把服务器内存吃满。
- 数据量大时也是边查边吐,不会把服务器内存吃满;
- **导出的只有你自己的记录**,不含任何别人的数据。
---
## 七、任务管理页:定时与补采
## 八、任务管理页:手动采集与只读的调度
![任务管理页](images/03-tasks.png)
![任务管理页(普通账号视图)](images/03b-tasks-user.png)
### 7.1 调度设置
### 8.1 调度信息(只读)
| 项 | 说明 |
普通账号在「采集调度」这块看到的是**只读表格**,不是表单:
| 项 | 你看到的 |
|---|---|
| 启用调度 | 总开关。关掉后只有手动采集会跑 |
| 每日时刻 | 逗号分隔的本地时刻,如 `09:00,17:00`。**保存即生效,不用重启** |
| 启动补跑 | 打开后,程序启动时会把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 超过多少小时就不补了(默认 12 小时) |
| 启用调度 | 开关状态(由管理员设定) |
| 每日时刻 | 如 `09:00,17:00`(由管理员设定) |
| 启动补跑 / 补跑宽限期 | 由管理员设定 |
| 采集参数 | 去「配置管理」看,同样是只读 |
> 调度线程在 Web 进程内,所以「关掉 Web」等于「关掉调度」。
> 如果偶尔忘了开机,靠「启动补跑」把错过的时刻补回来。
页面上会明确写着「**调度时刻与采集参数由管理员统一设定**,普通账号只读」。
要是你需要改时刻,找管理员 —— 这是**整机一套**的策略。
### 7.2 手动采集
> 为什么这么设计:一台部署只有一个调度线程,所有账号在同一时刻被采集。
> 让大家各定时刻只会让多个采集抢同一把写锁(SQLite 单写者),互相拖慢。
>
> **但你随时可以手动采。** 需要「现在就采一次」时用下面的按钮,不受调度限制。
### 8.2 手动采集(你可以用)
- **立即采集一次**:按断点续采,最常用的按钮。
- **按区间补采**:填 `起` / `止`,把这几天重新扫一遍。
用途:换了 Cookie 之后回补漏掉的日期;或怀疑某天数据不全时重扫。
重扫**不会产生重复**——主键去重,已存在的记录按「更早的本地时间」保留。
重扫**不会产生重复** —— 主键去重,已存在的记录按「更早的本地时间」保留。
> 这两个动作**只采集你自己的**数据,不会碰到别人的。
>
> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此
> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。
### 7.3 运行历史
### 8.3 运行历史
每次采集都留一条记录:触发方式、状态、耗时、抓取/新增/去重条数、退出码。
点「详情」看这一次的**逐行日志原文**,包括 `[warn]` 和 `[error]`。
每次采集都留一条记录:触发方式、状态、耗时、抓取 / 新增 / 去重条数、退出码。
你能看到的**只有你自己的运行历史**。
> 「日志」列在普通账号下显示为 `—`。逐行日志原文在「日志管理」页,
> 那是**管理员专属**(你访问会返回 403)。
---
## 八、配置管理页:参数与维护
## 九、配置管理页
![配置管理页](images/04-config.png)
![配置管理页(普通账号视图)](images/04b-config-user.png)
### 8.1 凭证
### 9.1 我的云端凭证(**这是你唯一能改的配置**)
| 字段 | 说明 |
|---|---|
| Cookie | 采集用的账号凭证。**只回显掩码**;留空保存 = 不修改(不会被清空) |
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳 |
| Cookie | **你本人账号**的凭证,密文入库。留空保存 = 不修改;填一个 `-` = 清空已保存的 Cookie |
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳(**两者必须取自同一次请求**) |
### 8.2 采集参数
保存后页面上只显示 `当前 Cookie:1238 字符,结尾 …c0ffe(2026-09-15 10:22 更新)` ——
页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过,
重新粘贴一次即可。
| 参数 | 默认 | 范围 | 说明 |
|---|---|---|---|
| `api_base` | `https://www.workbuddy.cn` | — | 接口基址(镜像 / 代理时改) |
| `api_path` | `/billing/meter/get-user-request-usage` | — | 接口路径 |
| `page_size` | 200 | 20 ~ 1000 | 单页条数。调大能减少请求次数,但单次更慢 |
| `rewind_minutes` | 2 | 0 ~ 120 | 断点回退分钟数。避免云端写入延迟导致漏数据 |
| `drift_tolerance_minutes` | 5 | 0 ~ 720 | 云端时间比本地早超过该值才告警 |
| `max_prompt` | 2048 | 0 ~ 20000 | `Prompt` 入库截断长度,`0` = 不截断 |
| `verify_days` | 0 | 0 ~ 90 | 每次采集后做整日完整性校验的天数,`0` = 关 |
| `timeout` | 30 | 5 ~ 300 | 单次 HTTP 超时(秒) |
| `ssl_verify` | 1(开) | — | 校验云端 HTTPS 证书。**只在自签/企业代理场景才关** |
> 凭证卡片上会有一行提示写着「**这一块是你唯一可以修改的配置**」——
> 看到它就找对地方了。
> **写错的值会被当场拒绝**并提示原因,不会污染配置(历史版本会因为一个手滑的数字
> 让采集整个跑不起来)。范围外的数、非数字都会在保存时被拦下。
### 9.2 采集参数(只读)
### 8.3 维护动作
普通账号在这一块看到的是**只读表格**,输入框不可编辑,页面上还有一张
「**为什么采集参数是只读的**」说明卡:
| 按钮 | 作用 | 何时用 |
| 参数 | 默认 | 说明 |
|---|---|---|
| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) |
| 导出全量 CSV | 全量导出到 `data/exports/` | 归档 / 交接 |
| 整理数据库 | `wal_checkpoint` + `VACUUM` | 删过数据后回收空间,或 WAL 文件偏大时 |
| `api_base` | `https://www.workbuddy.cn` | 接口基址(镜像 / 代理时改) |
| `api_path` | `/billing/meter/get-user-request-usage` | 接口路径 |
| `page_size` | 200 | 单页条数(20 ~ 1000) |
| `rewind_minutes` | 2 | 断点回退分钟数(0 ~ 120) |
| `drift_tolerance_minutes` | 5 | 云端时间漂移告警阈值(0 ~ 720) |
| `max_prompt` | 2048 | `Prompt` 入库截断长度,`0` = 不截断 |
| `verify_days` | 0 | 每次采集后做整日完整性校验的天数 |
| `timeout` | 30 | 单次 HTTP 超时(秒) |
| `ssl_verify` | 1(开) | 校验云端 HTTPS 证书,**只在自签 / 企业代理场景才关** |
> **为什么只读**:这些参数决定「一次采集怎么发请求」,属于实例级策略。
> 特别是 `ssl_verify` —— 谁能关掉它,谁就能让这台服务器在不校验证书的情况下
> 把所有人的 Cookie 发出去。这类开关必须收在管理员手里。
>
> 你确实需要调其中某一项时,把需求和理由告诉管理员,由他统一改 ——
> 改完对所有人立即生效,不用重启。
### 9.3 维护动作
| 按钮 | 作用 | 何时用 | 普通账号 |
|---|---|---|---|
| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) | ✅ 只补自己的 |
| 导出我的 CSV | 导出**你自己的**全量数据到 `data/exports/` | 归档 / 交接 | ✅ |
| 整理数据库 | `wal_checkpoint` + `VACUUM`(**整库操作**) | 删过数据后回收空间 | ❌ 仅管理员 |
这些动作**耗时且会占用写权限**,所以有二次确认。执行期间不要重复点击。
### 8.4 修改密码
### 9.4 修改密码
填「当前密码 / 新密码 / 确认新密码」。改完当前会话仍然有效,其他会话需要重新登录。
(同样的表单在「个人中心」也有一份。)
### 9.5 实例级设置(仅管理员可见)
普通账号**看不到这一块**。它包含「开放自助注册 / 同 IP 注册上限 / 验证码策略 /
验证码位数」,改动对**所有账号**生效。见 [第十一章](#十一管理员专属功能)。
---
## 九、日志管理页:出问题先看这里
## 十、个人中心
点右上角**你自己的名字**,进入个人中心(`/profile`)。
![个人中心](images/10-profile.png)
| 区块 | 能做什么 |
|---|---|
| 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 |
| 修改资料 | 改显示名、邮箱(**用户名只读**) |
| 修改登录密码 | 需要原密码;改完当前会话仍然有效 |
| 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻(只读) |
顶栏右侧会有一个 **「普通账号」** 小标签(管理员则是「管理员」)——
不确定自己是什么权限时看一眼这里。
> 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。
---
## 十一、管理员专属功能
> 普通账号可以跳过这一章 —— 里面的入口你都看不到(导航里不显示,直接敲地址返回 403)。
### 11.1 调度与采集参数(在「任务管理 / 配置管理」里改)
管理员在这两页看到的是**可编辑表单**,改动是**实例级**的,对**所有账号**生效:
| 项 | 默认 | 说明 |
|---|---|---|
| 启用调度 | 开 | 总开关。关掉后只有手动采集会跑 |
| 每日时刻 | `09:00,17:00` | 逗号分隔的本地时刻。**保存即生效,不用重启** |
| 启动补跑 | 开 | 启动时把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 12 小时 | 超过多少小时就不补了 |
| 采集参数 | 见 9.2 | 分页 / 回退 / 超时 / 截断 / 证书校验等 |
> ⚠️ **改 `schedule_times` 会清理槽位簿记**:系统只清掉「不再存在的时刻」对应的
> 槽位标记(所有账号一起清),**不做全清** —— 全清会让全部账号在宽限期内一起重采。
### 11.2 实例级设置
「配置管理」页最下方,改动对**所有账号**生效:
| 设置 | 默认 | 说明 |
|---|---|---|
| 开放自助注册 | 允许 | 关掉后登录页不再显示「自助注册」,只能由管理员建号 |
| 同 IP 每日注册上限 | 3 | 防止一个来源批量刷号;范围 1 ~ 50 |
| 验证码策略 | 始终要求 | `始终要求` / `仅连续失败 2 次后要求` / `关闭` |
| 验证码位数 | 4 | 4 ~ 6 位 |
> **验证码策略怎么选**:默认的「始终要求」最安全;「仅连续失败后要求」对天天登录的人
> 更友好,但会给机器人留出 2 次免验证码的尝试机会。**「关闭」只有在前面已经有
> 可信网关时才考虑。**
>
> 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删 ——
> 所以它不会随会话 Cookie 泄漏出去。
### 11.3 日志管理页
![日志管理页](images/05-logs.png)
三个区块:
**1. 采集运行历史**(可翻页 + 按状态筛 `ok` / `warn` / `error` / `running`)
每行可展开看**逐行日志原文**——排错时最有用的一块。
1. **采集运行历史**(可翻页 + 按状态筛 `ok` / `warn` / `error` / `running`)
—— 每行可展开看**逐行日志原文**,排错时最有用的一块;表格里多了「账号」列,
能看出是哪个人触发的。
2. **应用日志尾部** —— Web 进程自身的日志(启动、异常栈、调度动作)。
3. **操作审计** —— 谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、
建号删号、注册……
**2. 应用日志尾部**
Web 进程自身的日志(启动、异常栈、调度动作)。默认展示尾部若干行。
> 这是**全实例**视角(不是只你自己的)。普通账号访问本页返回 403,
> 所以管理员可以放心把这里当排错入口。
>
> 排错顺序建议:操作审计(有没有人动过)→ 采集历史(采集本身成不成功)→
> 应用日志(程序有没有异常)。
**3. 操作审计**
谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号……
可按**动作**筛选,支持翻页。
> 排错顺序建议:操作审计(有没有人动过) → 采集历史(采集本身成不成功) → 应用日志(程序有没有异常)。
---
## 十、用户管理页(仅管理员)
### 11.4 用户管理页
![用户管理页](images/06-users.png)
| 操作 | 说明 |
|---|---|
| 新建账号 | 填用户名 / 显示名 / 密码,可勾选管理员 |
| 改显示名 | 行内直接改,保存即生效 |
| 改权限 | 管理员 ↔ 普通用户 |
| 新建账号 | 填用户名 / 显示名 / 邮箱 / 密码 / 权限(**默认普通账号**) |
| 改显示名 | 行内直接改,点该行「保存」生效 |
| 改权限 | 管理员 ↔ 普通 |
| 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) |
| 改密码 | 给忘了密码的同事重置 |
| 删除 | 删除账号 |
| 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 |
内置三条护栏(前端和后端都拦):
列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**:
管理员能看到的只是「有多少」,看不到「是什么」,也看不到任何人的 Cookie。
内置四条护栏(前端置灰 + 后端再拦一次):
1. **不能取消自己的管理员身份**(防止把自己锁在门外);
2. **不能删除自己**;
3. **至少要保留一个账号**(防止系统变成没人能登录)。
2. **不能停用自己**;
3. **不能删除自己**;
4. **不能删掉最后一个启用的管理员**(防止系统变成没人能管)。
页面底部是「**账号操作审计**」:最近 20 条账号相关动作,含**注册**与**登录失败**记录
—— 想知道有没有人在撞密码,看这里。
> **给同事开账号时默认选「普通」**:管理员是能停用别人账号的角色,没必要扩大。
---
## 十一、常见任务速查
## 十二、信息安全与隐私安全
这一章讲清「系统为你做了什么」和「你该做什么」。前者是设计保证,后者只有你能做到。
### 12.1 系统为你做的(不需要你操心)
**凭证(Cookie)的保护**
| 措施 | 效果 |
|---|---|
| ChaCha20 + HMAC-SHA256 **encrypt-then-MAC** 静态加密 | 数据库文件被拷走也读不出明文;被篡改会解密失败而不是返回垃圾 |
| 加密主密钥与数据库**分开放** | 主密钥在 `data/instance.json`,与 `usage.sqlite` 不同文件;只拿到库 = 解不开 |
| 加密主密钥与会话签名密钥**分键位** | 轮换其中一个不会影响另一个(混用会导致「想换会话密钥却把所有 Cookie 弄坏」) |
| 页面 / 接口**永不回传明文** | 只回「长度 + 结尾 4 位」;`get_settings()` 对加密键一律置空 |
| 凭证**不参与实例级回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` —— 回落等于「用别人的身份采集」,是最严重的一类越权 |
| 明文取用只有**一条通道** | `db.get_secret()`,只在真正发采集请求时调用 |
**账号与访问控制**
| 措施 | 效果 |
|---|---|
| 密码只存哈希 | 不存明文;管理员也不知道你的密码 |
| 登录失败**双维度限速** | 同 IP、同用户名各自计数,任一连错 5 次锁定 10 分钟 |
| **先验验证码再比口令** | 否则攻击者能拿「密码对不对」当信号,提前跑完字典 |
| 验证码答案**只在服务端** | 浏览器只拿一个随机 id,网页源码里搜不到答案 |
| 会话 Cookie 加固 | `HttpOnly`(JS 读不到)+ `SameSite=Lax`(防跨站携带)+ 可选 `Secure` |
| **停用立即失效** | 每个请求都回查账号状态,不必等 12 小时会话过期 |
| 写请求需 CSRF 令牌 | 防「登录状态下被别人页面静默提交表单」 |
| 安全响应头 | CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` |
| 敏感接口 `no-store` | `/api/*` 与 `/captcha*` 不会被浏览器或中间层缓存 |
**数据隔离**
| 措施 | 效果 |
|---|---|
| 全链路按 `user_id` 过滤 | 查询、采集、调度、导出都带 `uid` 参数 |
| **`uid` 是必填位置参数** | 代码里漏传就直接 `TypeError`,不会静默退化成「返回全量」 |
| 索引以 `user_id` 打头 | 隔离既是安全边界,也是查询性能的前提 |
| 越权写**整单拒绝** | 普通账号提交只读项会被点名拒掉,不会「部分生效」 |
### 12.2 你要做的(系统替不了)
**① 自己的 Cookie 自己填,不要经手别人。**
Cookie 等于账号凭证。让同事代填 = 你的账号交到别人手里。
管理员也看不到你的 Cookie,所以**没有任何人需要知道它**。
**② 不要在公共机器上保留登录态。**
系统会话保持 12 小时。借别人的电脑用完就走右上角「退出」,
别只关标签页(会话在服务端仍然有效)。
**③ 密码别和其他系统复用。**
本系统没有邮件通道,忘密码只能找管理员重置,所以请用密码管理器记好。
**④ Cookie 会过期,这是好事。**
Cookie 通常随浏览器会话失效。过期后重新按 [4.2](#42-拿-cookie-的两种办法) 取一份即可,
历史数据完全不受影响。
**⑤ 导出 CSV 后注意存放。**
导出的 CSV 里有你的 `Prompt` 原文 —— 那是你真实的提问内容,可能包含代码、业务描述
甚至敏感信息。**别随手丢在共享目录或聊天群里**。用完删掉。
**⑥ 管理员注意:备份要连密钥一起。**
`data/instance.json` 里存着 Cookie 的加密主密钥。只备份 `usage.sqlite` 而不备它,
恢复后**所有账号的 Cookie 都会变成「无法解密」**,每个人都要重填一遍。
(反过来这也说明:这个文件本身就是高价值目标,权限要收紧。)
### 12.3 边界说明(诚实的那部分)
- **管理员在运维层面能看到比你想象中多的东西**:整机应用日志、所有人账号的
「记录条数与积分合计」、以及部署机器上的数据库文件本身。
管理员**看不到**你的 Cookie 明文、看不到你的 Prompt 内容、也看不到别人的记录内容 ——
但如果管理员对部署机器有 root 权限,理论上能改代码来绕过界面限制。
**这是所有自托管系统的共同前提**:信任部署这台机器的人。
所以如果是团队共用,请让「能登服务器」和「日常使用」的角色分开。
- **传输加密取决于你的部署方式**:纯局域网 `http://` 部署下,流量在网内是明文的。
要防中间人,需要按 [部署指南第六节](DEPLOYMENT.md#六反向代理与-https) 挂 HTTPS 反代,
并把 `WB_COOKIE_SECURE` 设为 `1`。
- **系统不做数据自动清理**:累积的记录会一直留着。要清理只能在数据库层面做,
属于管理员操作。
---
## 十三、常见任务速查
| 我想… | 怎么做 |
|---|---|
| 立刻采集一次 | 任务管理 → 立即采集一次 |
| 自己注册一个账号 | 登录页 → **自助注册**(需管理员开放注册) |
| 配好我的采集 | 配置管理 → 我的云端凭证 → 粘贴 Cookie → 保存 → 任务管理「立即采集一次」 |
| 立刻采集一次 | 任务管理 → 立即采集一次(只采我自己的) |
| 回补某几天的数据 | 任务管理 → 按区间补采,填起止日期 |
| 换 Cookie | 配置管理 → 凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
| 换我自己的 Cookie | 配置管理 → 我的云端凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
| 改我的显示名 / 邮箱 / 密码 | 右上角**点自己的名字** → 个人中心 |
| 看清我是什么权限 | 看顶栏右侧的标签:「管理员」还是「普通账号」 |
| 查我自己的采集有没有成功 | 任务管理 → 运行历史(只有我自己的) |
| 看清验证码 | **点验证码图片**换一张 |
| 导出某段时间的数据给别人 | 数据明细 → 选日期 → 导出 CSV |
| 导出全量存档 | 配置管理 → 维护动作 → 导出全量 CSV |
| 导出我的全量存档 | 配置管理 → 维护动作 → 导出我的 CSV |
| 找出最贵的请求 | 用量大屏 → 单笔 TOP |
| 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 |
| 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 |
| 同事忘记密码 | 用户管理 → 该行「改密码」 |
| 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出全量 CSV |
| 关掉自动采集 | 任务管理 → 关「启用调度」 |
| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 |
| 系统变慢了 | 配置管理 → 整理数据库;再不行看「十二」 |
| 改采集时刻 | ❌ 普通账号只读。找管理员改(或说明需求由他统一设) |
| 看日志排错 | ❌ 普通账号无权限。先用「任务管理 → 运行历史」,不够就找管理员 |
| 关掉自动采集 | ❌ 普通账号无权限。找管理员 |
| 系统变慢了 | 找**管理员**做「配置管理 → 整理数据库」;再不行看下一章 |
| 把数据备份走 | 找运维按 [部署指南第八节](DEPLOYMENT.md#八备份与恢复) 备份,或自己导出 CSV |
| 给同事开账号 | 管理员:用户管理 → 新建账号,权限选**普通**(或让同事自助注册) |
| 同事忘记密码 | 管理员:用户管理 → 该行「改密」 |
| 临时封掉某个账号 | 管理员:用户管理 → 该行「停用」(数据与 Cookie 保留) |
---
## 十二、常见问题
## 十四、常见问题
### 验证码看不清
点验证码图片**换一张**,不限次数、不消耗额度。图上刻意去掉了 `0 O 1 I L` 这几个易混字符,
只剩数字与不含它们的字母。也可以让管理员把「验证码位数」调成 4 位。
### 验证码明明填对了,还是提示错误
三种可能,按顺序排查:
1. **这张图已经用过了** —— 验证码是**一次性**的,输错一次、或登录成功之后它立刻作废,
必须点图片重新取一张;
2. **超过了 5 分钟** —— 有效期只有 5 分钟,换一张即可;
3. **跨了页面** —— 登录页取到的图不能拿去注册页用(两边的验证码是分开的)。
> 另外:如果这个来源已被锁定(连续失败 5 次),即使验证码正确也会被拒;等 10 分钟再试。
### 登录页看不到「自助注册」
说明管理员把注册关掉了。两条路:请管理员在「配置管理 → 实例级设置」里打开,
或直接请管理员在「用户管理」里给你建一个账号。
### 注册被拒,说来源已达上限
同一个 IP 每天默认最多注册 3 个账号。换个网络,或请管理员把
「同 IP 每日注册上限」调大(范围 1 ~ 50)。
### 我为什么改不了定时任务 / 看不到日志
**这是设计如此,不是权限配错了。** 注册出来的账号一律是普通账号,
它只能维护**本人**的 Cookie / User-Agent,然后查看**本人**的数据。
| 你想要的 | 实际可行的 |
|---|---|
| 改采集时刻 | ❌ 找管理员改(整机一套策略) |
| 改采集参数 | ❌ 把需求告诉管理员 |
| 看日志排错 | 先用「任务管理 → 运行历史」;需要逐行日志时找管理员 |
| 立刻采一次 | ✅ 「任务管理 → 立即采集一次」,随时可用 |
如果你确实需要管理员权限(比如要做整库维护),请让管理员在
「用户管理」里把你的权限改成管理员。
### 采集一直没跑(我的数据没更新)
按这个顺序查:
1. **你配 Cookie 了吗** —— 「配置管理 → 我的云端凭证」是否显示「已配置」。
没配的话采集到点会**跳过你**,运行历史里会有一条 `no_cookie`;
2. **Cookie 过期了吗** —— 运行历史里若有 `cookie_expired` / `401`,按
[4.2](#42-拿-cookie-的两种办法) 重新取一份;
3. **调度开着吗** —— 概览页「调度状态」看开关。关着的话就只有手动采集会跑;
4. **服务是不是停了** —— 问管理员。调度线程在服务端进程里,服务停了就不采。
> 采不到也不慌:修好后用「按区间补采」把那几天补回来就行(重扫不会产生重复)。
### 「Cookie 显示无法解密」
「配置管理 → 我的云端凭证」出现红字横幅,或卡片上写着 **无法解密**:这是说数据库里
存的 Cookie 密文,用当前的实例主密钥解不开了。常见原因是 `data/instance.json`
(里面存着 `cookie_key`)被删除、被替换,或者从别的机器拷了一份数据库过来。
**影响**:你这个账号的采集会失败,运行历史里是 `cookie_broken`。
**怎么办**:**重新粘贴一次你自己的 Cookie 即可**,历史数据不受影响。
(这个操作只有你本人能做 —— 别人看不到你的 Cookie,也就没法替你恢复。)
**怎么避免**:这是运维的事 —— 备份数据库时要**把 `instance.json` 一起备份**,
并且不要在容器之间混用。
### 我能不能看别人的用量
不能,管理员也不能。「用户管理」页只显示每个账号的记录条数与积分合计,看不到内容。
这是设计如此:Cookie 是账号级凭证,让它跨账号可见等于把别人的账号交出去。
如果团队确实需要合并统计,正确做法是**每个人各自导出 CSV,再在外部合并**。
### 采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新按 [3.2](#32-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。
Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了**——建议用
「办法 B」从已登录的浏览器里复制时,勾选「保持登录」。
Cookie 过期。重新按 [4.2](#42-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。
Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了** —— 建议从已登录的
浏览器里复制时,勾选「保持登录」。
### 采集成功但「新增 0 条」
大概率是**正常的**:断点续采意味着没有新请求时确实没有新增。
看「日志管理」里那一次的 `抓取` 条数:
看运行历史里那一次的 `抓取` 条数:
- `抓取 > 0,新增 = 0` → 云端返回的都是库里已存在的,正常;
- `抓取 = 0` → 该时段云端确实没有记录。
@@ -368,11 +766,11 @@ Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失
所有日期都按**部署机器的本地时区**(容器里由 `TZ` 决定,默认 `Asia/Shanghai`)计算。
如果服务器时区不是东八区,跨日的数据会落到相邻日期上。
Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。
这属于部署问题 —— 找管理员确认 `TZ=Asia/Shanghai`(Docker)或系统时区(裸机)。
### 导出的 CSV 在 Excel 里中文乱码
不会——导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件,
不会 —— 导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件,
而不是手工用记事本另存过的版本。
### 提示「采集正在进行中」
@@ -384,20 +782,34 @@ Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。
1. 强制刷新(`Ctrl+F5`)清掉旧缓存;
2. 检查浏览器控制台有没有资源 404;
3. 到「日志管理 → 应用日志」看有没有异常栈。
3. 找管理员看应用日志有没有异常栈(你这边看不到日志页)。
### 关掉浏览器后调度还在跑吗
在的。调度在**服务端进程**里,和浏览器无关。要停就去「任务管理」关调度开关,
或停掉服务。
在的。调度在**服务端进程**里,和浏览器无关。它是整机一套的,
所以你关不关浏览器都不影响它 —— 也正因为如此,它不归你管。
### 忘记管理员密码
### 忘记密码
在部署机器上执行:
**你自己忘了**:网页上没法自助重置(没有邮件通道),找管理员在
「用户管理 → 该行『改密』」给你设一个新的。
**管理员忘了**(或者被自己停用了),到部署机器上执行:
```bash
python manage.py passwd admin 新密码 # 裸机
python manage.py passwd admin 新密码 # 裸机
docker compose exec portal python manage.py passwd admin 新密码 # Docker
# 顺便把被停用的账号恢复启用
python manage.py passwd admin 新密码 --activate
# 需要新建一个管理员
python manage.py passwd alice 密码 --role admin
```
想看现在都有哪些账号、各自什么角色与状态,用:
```bash
python manage.py users
```
### 数据会丢吗
@@ -405,16 +817,16 @@ docker compose exec portal python manage.py passwd admin 新密码 # Docker
正本是 Docker 命名卷 `workbuddy-portal_wb_data` 里的 `usage.sqlite`(对应容器内 `/app/data`)。
`docker compose down` **不会删数据**;只有显式 `docker compose down -v`
或手动 `docker volume rm` 才会。备份方法见
[部署指南 6.2](DEPLOYMENT.md#62-备份)——对使用者的日常来说,更简单的做法是
「配置管理 → 维护动作 → 导出全量 CSV」留一份快照。
[部署指南第八节](DEPLOYMENT.md#八备份与恢复) —— 对使用者的日常来说,更简单的做法是
「配置管理 → 维护动作 → 导出我的 CSV」留一份快照。
### 能不能同时开多个采集进程
不能,也没必要。SQLite 单写者 + 文件锁的设计就是为了避免并发写。
真要跑多副本,除第一份外都要设 `WB_DISABLE_SCHEDULER=1`,
且只有一份能安全写——所以**不要**横向扩展这个服务。
且只有一份能安全写 —— 所以**不要**横向扩展这个服务。
---
更多技术细节见 [架构与设计说明](ARCHITECTURE.md)、[部署与运维指南](DEPLOYMENT.md)、
[接口参考](API.md)。
[接口参考](API.md)、[安全说明](../SECURITY.md)。
二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 93 KiB

之后

宽度:  |  高度:  |  大小: 334 KiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 169 KiB

之后

宽度:  |  高度:  |  大小: 643 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 478 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 269 KiB

之后

宽度:  |  高度:  |  大小: 810 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 509 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 140 KiB

之后

宽度:  |  高度:  |  大小: 708 KiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 638 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 271 KiB

之后

宽度:  |  高度:  |  大小: 833 KiB

二进制
查看文件
二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 98 KiB

之后

宽度:  |  高度:  |  大小: 498 KiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 386 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制文件未显示。

之前

宽度:  |  高度:  |  大小: 386 KiB

之后

宽度:  |  高度:  |  大小: 1.5 MiB

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 383 KiB

二进制
查看文件
二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 469 KiB

+309 -61
查看文件
@@ -7,41 +7,104 @@
采集 / 存储 / 呈现三件事都由本项目承担,不再依赖外部计划任务或自动化。
多用户说明:所有涉及「数据」或「凭证」的子命令都作用于**某一个账号**。
用 `-u/--user <用户名>` 指定;不指定时取「管理员优先、其次 id 最小」的那个
(老库升级后数据都在首个账号名下,所以不指定也能沿用旧习惯)。
唯独 `collect` 不带 `-u` 时会**逐个账号**跑一遍,与进程内调度线程的行为一致。
常用:
python manage.py init 初始化数据库(建表 + 默认配置 + 管理员)
python manage.py serve 启动 Web(0.0.0.0:8848,进程内含调度线程)
python manage.py serve --port 9000 --debug 开发模式(reloader 下调度只启动一份)
python manage.py collect 执行一次增量采集并退出(可用于外部计划任务)
python manage.py collect 为**所有已启用账号**各跑一次增量采集
python manage.py collect -u alice 只为 alice 采集
python manage.py migrate-csv [文件] 从旧版 CSV 存档导入(默认自动探测路径)
python manage.py import-xlsx <文件> 从官网导出的 xlsx 合入
python manage.py fill-prompt 补全缺失的 User Prompt
python manage.py export-csv [路径] 导出与官网同构的 CSV
python manage.py stats 只看存档概况,不联网
python manage.py passwd <用户名> [新密码] 重置登录密码
python manage.py status 查看调度与最近采集状态
python manage.py export-csv [路径] 导出与官网同构的 CSV(文件名带账号名)
python manage.py stats [-u 账号] 看存档概况,不联网
python manage.py users 列出所有账号及其数据量 / 凭证状态
python manage.py passwd <用户名> [新密码] 重置密码;账号不存在则创建
python manage.py passwd <用户名> --role admin 新建或提权为管理员
python manage.py status 查看各账号的调度与最近采集状态
python manage.py vacuum 整理数据库(checkpoint + VACUUM)
"""
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from workbuddy_portal import client, collect, config, db, query, scheduler # noqa: E402
from workbuddy_portal import security # noqa: E402
def _p(*a):
print(*a)
def _warn(*a):
print(*a)
# ---------------- 账号解析 ----------------
def _default_uid(conn):
"""没显式指定 `-u` 时的目标账号:管理员优先,其次 id 最小。
老库升级后全部数据都归到首个账号,所以这个默认值正好等价于旧行为;
全空库(只有实例级配置)返回 0,即「实例作用域」。
"""
row = conn.execute("SELECT id FROM users ORDER BY is_admin DESC, id LIMIT 1").fetchone()
return row["id"] if row else 0
def _resolve_uid(conn, name):
"""把 `-u` 的取值(用户名或数字 id)解析成 uid;解析不到返回 None。"""
if name is None or name == "":
return _default_uid(conn)
row = db.user_by_name(conn, str(name))
if row is None and str(name).isdigit():
row = db.user_by_id(conn, int(name))
if row is None:
_p("[error] 没有这个账号:%s(用 manage.py users 查看)" % name)
return None
return row["id"]
def _uid_or_fail(conn, name):
"""解析失败时返回 None,调用方自行 return 2。"""
uid = _resolve_uid(conn, name)
if uid is None:
return None
if uid == 0:
_warn("[warn] 库里还没有任何账号,本次按“实例作用域”执行(先跑 manage.py init)")
return uid
def _ua_of(conn, uid):
"""人话描述某个账号的 Cookie 状态(只看密文可解性,不碰明文)。"""
st = db.secret_state(conn, "cookie", uid)
if st["broken"]:
return "损坏(密钥换过,需重新粘贴)"
if not st["set"]:
return "未配置"
return "已配置 %d 字符" % st["chars"]
# ---------------- 初始化 / 服务 ----------------
def cmd_init(args):
db.init_db(admin_user=args.user, admin_password=args.password)
conn = db.connect()
try:
n = query.totals(conn)
uid = _default_uid(conn)
n = query.totals(conn, uid)
_p("数据库已就绪:%s" % config.SQLITE_PATH)
_p(" 存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 管理员:%s" % args.user)
_p(" 账号数:%d" % db.user_count(conn))
_p(" 主账号存档 %d 条 / %.2f 积分 / %d 个活跃日" % (n["records"], n["credits"], n["days"]))
_p(" 操作账号:%s" % args.user)
st = db.secret_state(conn, "cookie", uid)
if st["broken"]:
_p(" [warn] Cookie 密文无法解开(cookie_key 与写入时不一致),请登录后重新粘贴")
finally:
conn.close()
@@ -63,19 +126,58 @@ def cmd_serve(args):
app.run(host=host, port=port, threaded=True)
# ---------------- 采集 / 导入 / 导出 ----------------
def cmd_collect(args):
"""不带 -u 时逐个已启用账号采集;带 -u 时只采一个。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
r = collect.run_sync(trigger="cli")
except collect.Busy as e:
_p("[busy] %s" % e)
return 1
except collect.ApiError as e:
_p("[error] %s" % e)
return 3 if e.cookie_expired else 5
for line in r["lines"]:
_p(line)
return 0
if args.user:
uid = _resolve_uid(conn, args.user)
if uid is None:
return 2
targets = [uid]
else:
targets = [r["id"] for r in db.active_users(conn)]
if not targets:
_p("[error] 没有任何启用中的账号")
return 2
finally:
conn.close()
rc = 0
for uid in targets:
conn = db.connect()
try:
row = db.user_by_id(conn, uid)
who = row["username"] if row else "uid=%s" % uid
st = db.secret_state(conn, "cookie", uid)
if not st["set"] or st["broken"]:
_p("— %s:跳过(Cookie %s)" % (who, _ua_of(conn, uid)))
continue
finally:
conn.close()
_p("— %s:" % who)
try:
r = collect.run_sync(trigger="cli", uid=uid)
except collect.Busy as e:
_p(" [busy] %s" % e)
rc = rc or 1
continue
except collect.NotReady as e:
_p(" [skip] %s" % e)
continue
except db.SecretUnreadable as e:
_p(" [error] %s(请重新粘贴 Cookie)" % e)
rc = rc or 4
continue
except collect.ApiError as e:
_p(" [error] %s" % e)
rc = rc or (3 if e.cookie_expired else 5)
continue
for line in r["lines"]:
_p(" " + line)
return rc
def cmd_migrate_csv(args):
@@ -91,10 +193,13 @@ def cmd_migrate_csv(args):
return 2
conn = db.connect()
try:
collect.migrate_from_csv(conn, path, log=_p)
n = query.totals(conn)
_p("当前存档:%d 条 / %.2f 积分 / %s ~ %s" % (n["records"], n["credits"],
n["firstDay"], n["lastDay"]))
uid = _uid_or_fail(conn, args.user)
if uid is None:
return 2
collect.migrate_from_csv(conn, uid, path, log=_p)
n = query.totals(conn, uid)
_p("账号 uid=%s 当前存档:%d 条 / %.2f 积分 / %s ~ %s"
% (uid, n["records"], n["credits"], n["firstDay"], n["lastDay"]))
finally:
conn.close()
return 0
@@ -104,49 +209,91 @@ def cmd_import_xlsx(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
collect.import_xlsx(conn, args.path, log=_p)
uid = _uid_or_fail(conn, args.user)
if uid is None:
return 2
collect.import_xlsx(conn, uid, args.path, log=_p)
finally:
conn.close()
return 0
def cmd_fill_prompt(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
collect.fill_prompt(conn, log=_p)
uid = _uid_or_fail(conn, args.user)
if uid is None:
return 2
collect.fill_prompt(conn, uid, log=_p)
finally:
conn.close()
return 0
def cmd_export_csv(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
path, n = collect.export_csv(conn, args.path)
uid = _uid_or_fail(conn, args.user)
if uid is None:
return 2
row = db.user_by_id(conn, uid)
path, n = collect.export_csv(conn, uid, args.path,
username=(row["username"] if row else None))
_p("已导出 %d 条 -> %s" % (n, path))
finally:
conn.close()
return 0
# ---------------- 统计 ----------------
def cmd_stats(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
t = query.totals(conn)
# 先给一张全局概览:多用户下最常问的就是「一共多少、谁占多少」
rows = conn.execute(
"SELECT u.id, u.username, u.display_name, u.is_admin, u.status,"
" COUNT(r.request_id) AS records, COALESCE(SUM(r.credits),0) AS credits"
" FROM users u LEFT JOIN usage_records r ON r.user_id=u.id"
" GROUP BY u.id ORDER BY records DESC, u.id").fetchall()
total = conn.execute("SELECT COUNT(*) AS c, COALESCE(SUM(credits),0) AS s"
" FROM usage_records").fetchone()
_p("全库存档:%d 条 / %.2f 积分 / %d 个账号"
% (total["c"], total["s"], db.user_count(conn)))
if rows:
_p("")
_p("%-4s %-16s %-10s %-6s %-8s %8s %12s" %
("id", "用户名", "角色", "状态", "Cookie", "调用", "积分"))
for r in rows:
_p("%-4d %-16s %-10s %-6s %-8s %8d %12.2f" %
(r["id"], r["username"], "管理员" if r["is_admin"] else "普通",
"启用" if r["status"] == "active" else "停用",
_ua_of(conn, r["id"]), r["records"], r["credits"]))
uid = _resolve_uid(conn, args.user)
if uid is None:
return 2
t = query.totals(conn, uid)
if not t["records"]:
_p("存档为空,先跑 python manage.py migrate-csv 或 manage.py collect")
return
_p("")
_p("(uid=%s 没有数据;换个 -u,或先跑 manage.py migrate-csv / collect)" % uid)
return 0
_p("")
_p("== uid=%s 明细 ==" % uid)
_p("存档:%d 条 / %.2f 积分 / %d 个活跃日(%s ~ %s)"
% (t["records"], t["credits"], t["days"], t["firstDay"], t["lastDay"]))
_p("计费调用 %d · 免费调用 %d · 模型 %d · 客户端 %d"
% (t["billableCalls"], t["freeCalls"], t["models"], t["clients"]))
_p("")
_p("%-24s %8s %12s %10s %8s" % ("模型", "调用", "积分", "单次均价", "免费占比"))
for m in query.dims(conn)["model"]:
for m in query.dims(conn, uid)["model"]:
_p("%-24s %8d %12.2f %10.2f %7.0f%%"
% (m["name"], m["calls"], m["credits"], m["avgPerCall"], m["freeRate"] * 100))
runs = conn.execute("SELECT id,trigger,status,started_at,added,dup,total,message"
" FROM collect_runs ORDER BY id DESC LIMIT 5").fetchall()
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 5",
(uid,)).fetchall()
if runs:
_p("")
_p("最近采集:")
@@ -156,13 +303,49 @@ def cmd_stats(args):
r["total"], r["message"] or ""))
finally:
conn.close()
return 0
def cmd_users(args):
"""列出账号:角色 / 状态 / 数据量 / 凭证状态 / 最近登录。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
rows = conn.execute(
"SELECT u.*, COUNT(r.request_id) AS records,"
" COALESCE(SUM(r.credits),0) AS credits,"
" MAX(r.day) AS last_day"
" FROM users u LEFT JOIN usage_records r ON r.user_id=u.id"
" GROUP BY u.id ORDER BY u.id").fetchall()
if not rows:
_p("还没有任何账号。跑 manage.py init 建管理员,或让用户自助注册。")
return 0
_p("%-4s %-16s %-12s %-6s %-6s %10s %8s %12s %s" %
("id", "用户名", "显示名", "角色", "状态", "Cookie", "调用", "积分", "最近登录 IP"))
for r in rows:
_p("%-4d %-16s %-12s %-6s %-6s %10s %8d %12.2f %s" %
(r["id"], r["username"], r["display_name"] or "",
"管理员" if r["is_admin"] else "普通",
"启用" if r["status"] == "active" else "停用",
_ua_of(conn, r["id"]), r["records"], r["credits"],
r["last_login_ip"] or "—"))
_p("")
_p("提示:cookie_key 或 secret_key 可在 data/instance.json 里找到,"
"二者权限等同管理员口令,切勿随仓库分发。")
finally:
conn.close()
return 0
# ---------------- 凭证 / 口令 ----------------
def cmd_import_creds(args):
"""把 VSCode 设置里的 cookie / userAgent 接管进数据库(一次性迁移用)。"""
db.init_db(create_admin=False)
conn = db.connect()
try:
uid = _uid_or_fail(conn, args.user)
if uid is None:
return 2
found = client.read_vscode_creds()
if not found:
_p("[error] 没找到 VSCode 系编辑器的 settings.json")
@@ -176,11 +359,15 @@ def cmd_import_creds(args):
_p("[error] 这些文件里都没有 codebuddyUsage.cookie,请到「配置管理」页手工粘贴")
return 2
cookie, ua = hit
db.set_setting(conn, "cookie", cookie)
# 一律走 set_secret(内部就是 set_setting),值在落库前完成加密
db.set_secret(conn, "cookie", cookie, uid)
if ua:
db.set_setting(conn, "user_agent", ua)
db.audit(conn, "import_creds", "cli", "从 VSCode 设置导入凭证(%d 字符)" % len(cookie), "127.0.0.1")
_p("已导入 Cookie(%d 字符)与 User-Agent(%s)" % (len(cookie), "有" if ua else "无"))
db.set_setting(conn, "user_agent", ua, uid)
row = db.user_by_id(conn, uid)
db.audit(conn, "import_creds", (row["username"] if row else "cli"),
"从 VSCode 设置导入凭证(%d 字符)" % len(cookie), "127.0.0.1", uid)
_p("已把 Cookie(%d 字符)与 User-Agent(%s)写入账号 uid=%s"
% (len(cookie), "有" if ua else "无", uid))
finally:
conn.close()
return 0
@@ -188,41 +375,83 @@ def cmd_import_creds(args):
def cmd_passwd(args):
db.init_db(create_admin=False)
from workbuddy_portal.security import hash_password
conn = db.connect()
try:
row = conn.execute("SELECT id FROM users WHERE username=?", (args.user,)).fetchone()
pwd = args.password or "admin123"
row = db.user_by_name(conn, args.user)
pwd = args.password
is_admin = 1 if args.role == "admin" else 0
if row:
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), row["id"]))
_p("已重置 %s 的密码" % args.user)
sets, vals = [], []
if pwd:
sets.append("password_hash=?")
vals.append(security.hash_password(pwd))
if args.role:
sets.append("is_admin=?")
vals.append(is_admin)
if args.activate:
sets.append("status='active'")
if not sets:
_p("没给新密码也没给 --role,什么都没改")
return 0
vals.append(row["id"])
conn.execute("UPDATE users SET %s WHERE id=?" % ",".join(sets), vals)
_p("已更新账号 %s(%s)" % (args.user, ",".join(
x.split("=")[0] for x in sets)))
else:
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,1,?)", (args.user, hash_password(pwd), args.user, db.now_str()))
_p("已创建用户 %s" % args.user)
_p("新密码:%s" % pwd)
if not pwd:
_p("[error] 新账号必须给出密码")
return 2
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,?,'active',?)",
(args.user, security.hash_password(pwd), args.user, is_admin, db.now_str()))
uid = conn.execute("SELECT id FROM users WHERE username=?",
(args.user,)).fetchone()["id"]
db.audit(conn, "user_create", "cli", "命令行创建账号 %s" % args.user,
"127.0.0.1", uid)
_p("已创建账号 %s(uid=%s,%s)"
% (args.user, uid, "管理员" if is_admin else "普通"))
if pwd:
_p("密码:%s" % pwd)
finally:
conn.close()
return 0
# ---------------- 状态 / 维护 ----------------
def cmd_status(args):
db.init_db(create_admin=False)
conn = db.connect()
try:
_p("服务器时间:%s" % db.now_str())
_p("调度开关:%s" % ("启用" if db.get_bool(conn, "schedule_enabled", True) else "停用"))
_p("每日时刻:%s" % (", ".join(scheduler.slots(conn)) or "—"))
nxt = scheduler.next_run_at(conn)
_p("下次执行:%s" % (nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else "—"))
_p("Cookie:%s" % ("已配置" if (db.get_setting(conn, "cookie") or "").strip() else "未配置"))
_p("互斥锁:%s" % ("存在(有采集在跑)" if os.path.exists(collect.LOCK_PATH) else "不存在"))
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
if last:
_p("最近采集:#%d %s %s %s" % (last["id"], last["started_at"], last["status"],
last["message"] or ""))
else:
_p("最近采集:无")
_p("(注意:调度线程只在 manage.py serve 进程内运行)")
_p("调度总开关(实例级):%s"
% ("启用" if db.get_bool(conn, "schedule_enabled", True) else "停用"))
users = db.active_users(conn)
if not users:
_p("(没有任何启用中的账号。先 manage.py init 建管理员,"
"或在「用户管理」页启用一个账号)")
for u in users:
uid = u["id"]
_p("")
_p("== uid=%d %s%s ==" % (uid, u["username"],
"(管理员)" if u["is_admin"] else ""))
_p(" 调度:%s / 每日 %s" %
("启用" if db.get_bool(conn, "schedule_enabled", True, uid) else "停用",
", ".join(scheduler.slots(conn, uid)) or "—"))
nxt = scheduler.next_run_at(conn, uid)
_p(" 下次执行:%s" % (nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else "—"))
_p(" Cookie:%s" % _ua_of(conn, uid))
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=?"
" ORDER BY id DESC LIMIT 1", (uid,)).fetchone()
if last:
_p(" 最近采集:#%d %s %s %s" % (last["id"], last["started_at"],
last["status"], last["message"] or ""))
else:
_p(" 最近采集:无")
_p("")
_p("(注意:调度线程只在 manage.py serve 进程内运行;"
"多实例部署时其余实例要设 WB_DISABLE_SCHEDULER=1)")
finally:
conn.close()
@@ -238,7 +467,8 @@ def cmd_vacuum(args):
conn.execute("PRAGMA optimize")
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
_p("数据库整理完成:%s → %s(%+d 字节)" % (_human(before), _human(after), after - before))
_p("存档 %d 条记录" % collect.record_count(conn))
n = conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
_p("全库存档 %d 条记录" % n)
finally:
conn.close()
return 0
@@ -251,6 +481,11 @@ def _human(n):
n /= 1024.0
# ---------------- 参数表 ----------------
def _add_user_opt(p, help_text="作用账号(用户名或 uid),默认取管理员 / 最小 id"):
p.add_argument("-u", "--user", default=None, help=help_text)
def main():
ap = argparse.ArgumentParser(description="WorkBuddy Portal(workbuddy-portal)",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -258,7 +493,7 @@ def main():
sub = ap.add_subparsers(dest="cmd")
s = sub.add_parser("init", help="初始化数据库")
s.add_argument("--user", default="admin")
s.add_argument("--user", default="admin", help="首个管理员用户名(仅库为空时生效)")
s.add_argument("--password", default=None)
s.set_defaults(func=cmd_init)
@@ -269,36 +504,49 @@ def main():
s.add_argument("--no-scheduler", action="store_true", help="不启动进程内调度线程")
s.set_defaults(func=cmd_serve)
s = sub.add_parser("collect", help="执行一次增量采集")
s = sub.add_parser("collect", help="执行一次增量采集(默认所有启用账号)")
_add_user_opt(s, "只采这一个账号;不传则逐个启用账号采集")
s.set_defaults(func=cmd_collect)
s = sub.add_parser("migrate-csv", help="从旧版 CSV 导入")
s.add_argument("path", nargs="?")
_add_user_opt(s, "这份老存档算谁的")
s.set_defaults(func=cmd_migrate_csv)
s = sub.add_parser("import-xlsx", help="从官网 xlsx 导入")
s.add_argument("path")
_add_user_opt(s)
s.set_defaults(func=cmd_import_xlsx)
s = sub.add_parser("fill-prompt", help="补全缺失的 User Prompt")
_add_user_opt(s)
s.set_defaults(func=cmd_fill_prompt)
s = sub.add_parser("export-csv", help="导出 CSV")
s.add_argument("path", nargs="?")
_add_user_opt(s)
s.set_defaults(func=cmd_export_csv)
s = sub.add_parser("stats", help="存档概况")
s = sub.add_parser("stats", help="存档概况(先全库概览,再给指定账号明细)")
_add_user_opt(s)
s.set_defaults(func=cmd_stats)
s = sub.add_parser("users", help="列出所有账号及其数据量 / 凭证状态")
s.set_defaults(func=cmd_users)
s = sub.add_parser("import-creds", help="从 VSCode 设置导入 cookie / UA 到数据库")
_add_user_opt(s)
s.set_defaults(func=cmd_import_creds)
s = sub.add_parser("passwd", help="重置 / 创建登录账号")
s.add_argument("user")
s.add_argument("password", nargs="?")
s.add_argument("--role", choices=["admin", "user"], default=None,
help="不提则保持原角色;新建时默认普通账号")
s.add_argument("--activate", action="store_true", help="顺便把状态改回启用")
s.set_defaults(func=cmd_passwd)
s = sub.add_parser("status", help="调度与最近采集状态")
s = sub.add_parser("status", help="各账号的调度与最近采集状态")
s.set_defaults(func=cmd_status)
s = sub.add_parser("vacuum", help="整理数据库(checkpoint + VACUUM)")
+334
查看文件
@@ -0,0 +1,334 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""文档自检:内部链接 / 跨文件锚点 / 图片引用 / 绝对路径泄漏 / 版本一致性 / 产品名硬编码。
文档一旦互相引用(README → docs/DEPLOYMENT.md#某节),章节重排就会让锚点**静默失效** ——
Markdown 不会报错,页面只是不跳转、图片只是显示裂图。这个脚本把这类问题变成可执行的断言。
用法:
python tools/check_docs.py # 有问题则退出码 1
python tools/check_docs.py --no-fail # 只看报告,不因问题而失败
退出码:0 通过,1 发现问题。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
# ---------------------------------------------------------------- 常量
# 递归扫描时跳过的目录。**自动发现所有 .md**,而不是写死文件名列表 ——
# 写死列表的版本曾漏掉 THIRD-PARTY-NOTICES.md 与 CODE_OF_CONDUCT.md
# (新增文档时必然漏,而且没人会发现)。
SKIP_DIRS = {
".git", ".hg", ".svn", "node_modules", "vendor", "dist", "build",
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache", ".idea", ".vscode",
}
# markdown 链接:[文本](目标)
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
# markdown 图片:![alt](目标)
IMG_RE = re.compile(r"!\[[^\]]*\]\(([^)\s]+)\)")
# markdown 标题:# / ## / ... (用于生成锚点)
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
# 显式 HTML 锚点:<a name="x"></a> 或 <a id="x"></a>
HTML_ANCHOR_RE = re.compile(r"<a\s+(?:name|id)=[\"']([^\"']+)[\"']")
# 代码块围栏(三反引号或三波浪线)
FENCE_RE = re.compile(r"^\s*(```|~~~)")
# 绝对路径泄漏:正向白名单写不出来,反着匹配已知模式足够有效。
# 每个模式的**第 1 个捕获组**是「用户名那一段」,用来判占位符。
LEAK_PATTERNS = [
(re.compile(r"[A-Za-z]:[\\/](?:Users|users)[\\/]([^\\/\s\"'`)]+)"),
"用户目录绝对路径"),
(re.compile(r"[A-Za-z]:[\\/]Documents and Settings[\\/]([^\\/\s\"'`)]+)"),
"用户目录绝对路径"),
(re.compile(r"/(?:home|Users)/([A-Za-z0-9._-]+)/"), "用户目录绝对路径"),
]
# 占位符豁免:`C:\Users\<用户名>\…` 是**良好实践**,不是泄漏。
PLACEHOLDER_RE = re.compile(
r"[<>%*{}]|^\.{2,}$|^[-_]+$"
r"|^(?:user|users|username|user-?name|your-?name|youruser|"
r"用户名|你的用户名|xxx+|yyy+|zzz+|aaa+|example|placeholder|"
r"me|someone|nobody)$",
re.IGNORECASE,
)
# 版本一致性:这四处必须互相一致
VERSION_INIT = os.path.join("workbuddy_portal", "__init__.py")
INIT_VER_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.M)
DOCKERFILE = "Dockerfile"
OCI_VER_RE = re.compile(r'org\.opencontainers\.image\.version\s*=\s*"([^"]+)"')
README_VER_RE = re.compile(r"^\|\s*版本\s*\|\s*v?([0-9][^\s|]*)\s*\|", re.M)
CHANGELOG_VER_RE = re.compile(r"^##\s*\[?v?([0-9][^\s\]—-]*)", re.M)
# 产品名硬编码:应走 config.PROJECT_NAME 等上下文变量,不写进模板/JS
HARDCODE_NEEDLES = ["WorkBuddy Portal", "WorkBuddy 用量"]
HARDCODE_DIRS = [
os.path.join("workbuddy_portal", "web", "templates"),
os.path.join("workbuddy_portal", "web", "static", "js"),
]
# ---------------------------------------------------------------- 基础工具
def read(path: str) -> str:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
def strip_code_blocks(text: str) -> str:
"""去掉围栏代码块内容 —— 里面的 `#` 不是标题,里面的链接不该被当链接。
用空串占位(保留换行),这样**行号不会错位**,报错才能定位到真实位置。
"""
out, in_fence = [], False
for line in text.splitlines():
if FENCE_RE.match(line):
in_fence = not in_fence
out.append("")
continue
out.append("" if in_fence else line)
return "\n".join(out)
def slugify(text: str) -> str:
"""把标题/锚点文本转成可比对的 key。
**刻意不逐字复刻 GitHub/Gitea 的 slug 算法。** 各家的标点处理规则并不一致
(GitHub 会删 `+`/`:` 却保留 `、`/`:`,而「连续空格是折叠成一个连字符
还是每个空格一个连字符」也随实现而变)。照着某个实现写死,换个托管平台
就会批量误报,反而让真问题淹掉。
这里只保留「有效字符」:小写字母、数字、汉字。标点、空格、连字符**全部丢弃**。
于是 `八、配置系统:写时校验 + 读时兜底` 与链接里的
`八配置系统写时校验--读时兜底` 归一后相等 —— 章节被重命名时,
有效字符会变,锚点仍然照抓不误。
代价:仅标点不同的两个标题会被视为同一锚点(极端罕见,可接受)。
"""
return re.sub(r"[^0-9a-z\u4e00-\u9fff]", "", text.lower())
def is_external(target: str) -> bool:
"""外部链接(http: / mailto: 等)不检查。"""
return bool(re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target))
def looks_like_placeholder(segment: str) -> bool:
return bool(PLACEHOLDER_RE.search(segment.strip()))
def discover(root: str) -> list[str]:
"""递归找出所有 .md,返回相对 root 的 posix 路径。"""
found: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if fn.lower().endswith((".md", ".markdown")):
rel = os.path.relpath(os.path.join(dirpath, fn), root)
found.append(rel.replace("\\", "/"))
return sorted(found)
_anchor_cache: dict[str, set[str]] = {}
def anchors_of(abs_path: str) -> set[str]:
"""一个 md 文件里所有可跳转的锚点(标题 + 显式 HTML 锚点)。"""
if abs_path not in _anchor_cache:
text = strip_code_blocks(read(abs_path))
got: set[str] = set()
for line in text.splitlines():
m = HEADING_RE.match(line)
if m:
got.add(slugify(m.group(2)))
for m in HTML_ANCHOR_RE.finditer(text):
got.add(slugify(m.group(1)))
_anchor_cache[abs_path] = got
return _anchor_cache[abs_path]
# ---------------------------------------------------------------- 各检查项
def check_links(root: str, files: list[str]) -> list[str]:
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
base_dir = os.path.dirname(abs_path)
text = strip_code_blocks(read(abs_path))
for lineno, line in enumerate(text.splitlines(), 1):
for m in LINK_RE.finditer(line):
target = m.group(2)
if is_external(target):
continue
# 纯页内锚点:指回本文件
if target.startswith("#"):
frag = target[1:]
if frag and slugify(frag) not in anchors_of(abs_path):
problems.append(f"{rel}:{lineno} 页内锚点失效 {target}")
continue
path_part, _, frag = target.partition("#")
if not path_part:
continue
resolved = os.path.normpath(os.path.join(base_dir, path_part))
if not os.path.exists(resolved):
problems.append(f"{rel}:{lineno} 链接目标不存在 {target}")
continue
if frag and resolved.lower().endswith((".md", ".markdown")):
if slugify(frag) not in anchors_of(resolved):
problems.append(f"{rel}:{lineno} 跨文件锚点失效 {target}")
return problems
def check_images(root: str, files: list[str]) -> list[str]:
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
base_dir = os.path.dirname(abs_path)
text = strip_code_blocks(read(abs_path))
for lineno, line in enumerate(text.splitlines(), 1):
for m in IMG_RE.finditer(line):
src = m.group(1)
if is_external(src):
continue
resolved = os.path.normpath(os.path.join(base_dir, src))
if not os.path.exists(resolved):
problems.append(f"{rel}:{lineno} 图片不存在 {src}")
return problems
def check_path_leaks(root: str, files: list[str]) -> list[str]:
"""扫绝对路径 —— 文档里出现多半是从本机命令里抄进来的(会连带泄漏用户名)。
命中后请**逐条人工判断**:占位符(`C:\\Users\\<用户名>`)已豁免,
但 `os.path.join(home, "AppData", ...)` 这类合法的路径发现代码不会命中
(它不含盘符或 `/home/` 前缀)。这里只报告,不自动修改。
"""
problems: list[str] = []
for rel in files:
abs_path = os.path.join(root, rel.replace("/", os.sep))
for lineno, line in enumerate(read(abs_path).splitlines(), 1):
for pat, label in LEAK_PATTERNS:
for m in pat.finditer(line):
seg = m.group(1) if m.groups() else ""
if seg and looks_like_placeholder(seg):
continue
problems.append(f"{rel}:{lineno} {label} {m.group(0)}")
return problems
def check_versions(root: str) -> list[str]:
"""版本号四处(__init__ / Dockerfile / README / CHANGELOG)必须一致。"""
found: dict[str, str] = {}
sources = [
(VERSION_INIT, INIT_VER_RE),
(DOCKERFILE, OCI_VER_RE),
("README.md", README_VER_RE),
(os.path.join("docs", "CHANGELOG.md"), CHANGELOG_VER_RE),
]
for rel, pat in sources:
p = os.path.join(root, rel)
if os.path.isfile(p):
m = pat.search(read(p))
if m:
found[rel.replace("\\", "/")] = m.group(1)
if not found:
return ["版本一致性: 一个版本号都没找到,检查脚本本身"]
if len(set(found.values())) > 1:
detail = "、".join("%s=%s" % (k, v) for k, v in sorted(found.items()))
return ["版本不一致: %s" % detail]
return []
def check_name_hardcode(root: str) -> list[str]:
"""产品名应走上下文变量(config.PROJECT_NAME),不该硬编码进模板/JS。"""
problems: list[str] = []
for d in HARDCODE_DIRS:
full = os.path.join(root, d)
if not os.path.isdir(full):
continue
for dirpath, _dirnames, filenames in os.walk(full):
for fn in filenames:
if not fn.lower().endswith((".html", ".js")):
continue
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, root).replace("\\", "/")
for i, line in enumerate(read(fp).splitlines(), 1):
for n in HARDCODE_NEEDLES:
if n in line:
problems.append(
f"{rel}:{i} 疑似硬编码产品名「{n}」(应走上下文变量)")
return problems
# ---------------------------------------------------------------- main
def main() -> int:
ap = argparse.ArgumentParser(description="文档自检")
ap.add_argument("--root", default=".", help="仓库根(默认当前目录)")
ap.add_argument("--no-fail", action="store_true",
help="即使发现问题也返回 0(仅用于人工查看报告)")
ap.add_argument("--strict", action="store_true", help=argparse.SUPPRESS)
ap.add_argument("--quiet", action="store_true", help="只打印问题")
args = ap.parse_args()
root = os.path.abspath(args.root)
if not os.path.isdir(root):
print("--root 不是目录: %s" % root)
return 1
files = discover(root)
if not files:
print("没找到任何 .md 文件。--root 是否正确?"
"(注意:Git Bash 的 /tmp/x 交给原生 Python 会变成 C:\\tmp\\x)")
return 1
sections = [
("内部链接与锚点", check_links(root, files)),
("图片引用", check_images(root, files)),
("绝对路径泄漏", check_path_leaks(root, files)),
("版本一致性", check_versions(root)),
("产品名硬编码", check_name_hardcode(root)),
]
total = sum(len(p) for _, p in sections)
if not args.quiet:
print("扫描 %d 个 Markdown 文件:" % len(files))
for rel in files:
print(" - %s" % rel)
print()
for title, problems in sections:
if args.quiet and not problems:
continue
print("=== %s ===" % title)
if problems:
for p in problems:
print(" [!!] %s" % p)
else:
print(" OK")
print()
print("RESULT: %d 处问题" % total)
if total == 0:
print("文档自检全部通过。")
# 默认「有问题就失败」——「报出 7 处问题却返回 0」是个静默无用的陷阱,
# 想只看报告请显式加 --no-fail。
if total and not args.no_fail:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
+258 -23
查看文件
@@ -5,29 +5,38 @@
"""端到端验收:对**运行中的**服务发真实 HTTP 请求,走完整登录/CSRF/API 链路。
与 tests 里用 Flask test_client 的冒烟测试互补——这里验证的是「真的起起来了、
真的能登录、真的能取到数」,适合部署到局域网后随手跑一遍。
与 tools/smoke.py 的分工:smoke 用 Flask test_client 直接渲染模板、不发网络请求;
本脚本确认真的是「起起来了、能登录、能取到数」,适合部署到局域网后随手跑一遍。
用法:
python tools/check_live.py # 默认 http://127.0.0.1:8848
python tools/check_live.py --base http://192.168.1.50:8848 # 换成你的部署主机
python tools/check_live.py -u admin -p 你的密码
python tools/check_live.py --as alice:她的密码 # 额外跑一遍**普通账号**的越权面
python tools/check_live.py --from 2026-09-08 --to 2026-09-14
`--as` 那一节会真的发越权请求(改调度 / 改采集参数 / 读日志),
期望全部被拒;不会创建或删除任何账号,所以请自己先准备一个普通账号。
退出码:0 全通过;1 有失败项(会打印失败清单)。
注意:脚本会读取窗口数据但**不写库**(不触发采集、不改配置),可安全反复运行。
注意:脚本会读窗口数据、会走登录(登录本身会更新 last_login_at),
但**不触发采集、不改任何配置**,可安全反复运行。
"""
from __future__ import annotations
import argparse
import base64
import http.cookiejar
import json
import os
import re
import sqlite3
import sys
import urllib.error
import urllib.parse
import urllib.request
import zlib
from datetime import datetime
OK = 0
@@ -40,6 +49,31 @@ def _d(s: str):
return datetime.strptime(s, "%Y-%m-%d")
def decode_session(cj) -> dict:
"""从 Flask 会话 cookie 里解出那份**未加密**的载荷。
Flask 的会话是「签名 + base64,**不加密**」的 —— 也就是说持有 cookie 的人
就能读到里面的内容。本项目因此把验证码答案放在服务端 captchas 表里,
会话里只留一个随机 id;本函数存在的意义就是取出那个 id,
好让自动化验收能跨过验证码这一关(顺便也验证了「答案不在会话里」)。
"""
for c in cj:
if not c.name.startswith("workbuddy_portal_sid"):
continue
seg = urllib.parse.unquote(c.value).split(".")[0]
seg += "=" * (-len(seg) % 4)
try:
raw = base64.urlsafe_b64decode(seg)
try:
raw = zlib.decompress(raw) # 某些版本的 itsdangerous 会压
except zlib.error:
pass
return json.loads(raw.decode("utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def chk(name: str, cond: bool, extra: str = "") -> None:
global OK, FAIL
if cond:
@@ -59,9 +93,10 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
class Live:
def __init__(self, base: str, timeout: int = 20):
def __init__(self, base: str, timeout: int = 20, db_path: str | None = None):
self.base = base.rstrip("/")
self.timeout = timeout
self.db_path = db_path
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
self.cj = http.cookiejar.CookieJar()
self.op = urllib.request.build_opener(
@@ -114,6 +149,62 @@ class Live:
st, body = self.get(path)
return json.loads(body) if st == 200 else {}
def raw(self, path: str):
"""返回 (status, headers, bytes)——验证码/响应头这类要原始字节的场景用。"""
try:
r = self.op.open(urllib.request.Request(self.base + path), timeout=self.timeout)
return r.status, r.headers, r.read()
except urllib.error.HTTPError as e:
return e.code, e.headers, e.read()
def form_csrf(self, path: str) -> str:
"""取某个页面里的 CSRF 隐藏域(该页面必须与当前会话同源)。"""
_, html = self.get(path)
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
return m.group(1) if m else ""
# ---- 验证码辅助(仅验收脚本用)----
def solve_captcha(self, purpose: str):
"""取一张图 -> 从会话里读 id -> 从本地库里取答案。返回 (答案, 会话载荷)。"""
self.raw("/captcha.png?purpose=" + purpose)
sess = decode_session(self.cj)
cid = sess.get("cap_" + purpose)
if not cid or not self.db_path or not os.path.exists(self.db_path):
return None, sess
try:
con = sqlite3.connect(self.db_path)
try:
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None, sess
return (row[0] if row else None), sess
def login(self, user: str, pwd: str, nxt: str = "", follow: bool = True):
"""完整登录(验证码策略为 always 时自动解)。
follow=False 时返回原始 (status, Location),用于验证跳转目标是否安全。
返回 (status, location, need_captcha, session_payload)。
"""
html = self.get("/login")[1]
need_cap = 'name="captcha"' in html
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
data = {"username": user, "password": pwd, "_csrf": m.group(1) if m else ""}
if nxt:
data["next"] = nxt
sess = {}
if need_cap:
ans, sess = self.solve_captcha("login")
if ans is None:
return None, None, True, sess
data["captcha"] = ans
if follow:
st, _ = self.post("/login", data)
return st, None, need_cap, sess
st, loc = self.post_raw("/login", data)
return st, loc, need_cap, sess
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
print("== 1. 未登录访问受保护资源 ==")
@@ -123,13 +214,15 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
st, _ = L.get(p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
print("== 2. 登录(含 CSRF) ==")
print("== 2. 登录(含 CSRF;验证码策略为 always 时自动解) ==")
st, html = L.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
chk("登录页含 CSRF 隐藏域", bool(m))
st, _ = L.post("/login", {"username": user, "password": pwd,
"_csrf": m.group(1) if m else ""})
chk("登录页含 CSRF 隐藏域", bool(re.search(r'name="_csrf"\s+value="([^"]+)"', html)))
st, _, need_cap, sess = L.login(user, pwd)
chk("登录成功", st in (200, 302), "status=%s" % st)
if need_cap:
# 会话里只应有 id,不该有答案本身
chk("会话里只存验证码 id(不是答案)", bool(sess.get("cap_login")),
"cap_login=%s" % (sess.get("cap_login") or "无"))
st, html = L.get("/")
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
@@ -212,11 +305,29 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
print("== 7. 凭据不外泄 ==")
stj = L.jget("/api/settings")
chk("settings 无 cookie 明文字段", "cookie" not in stj, "keys=%s" % list(stj.keys()))
# 契约:settings 里 cookie 这个键**必须为空**(db.get_settings 统一置空),
# 真正的状态只通过 cookie_hint / cookie_broken 这两个派生字段暴露。
chk("settings 里 cookie 字段为空串",
"cookie" in stj and not str(stj.get("cookie") or "").strip(),
"cookie=%r" % stj.get("cookie"))
chk("settings 用 cookie_hint/cookie_broken 代替明文",
"cookie_hint" in stj and "cookie_broken" in stj)
chk("settings 仅回 cookie_hint 掩码",
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
"hint=%s" % stj.get("cookie_hint"))
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
chk("settings 回传个人可写键清单(应为 cookie/user_agent)",
set(stj.get("_userKeys") or []) == {"cookie", "user_agent"},
"%s" % stj.get("_userKeys"))
chk("settings 标明角色", stj.get("_role") == "admin", "%s" % stj.get("_role"))
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
cipher_re = re.compile(r"v1\.[A-Za-z0-9+/=]{8,}\.[A-Za-z0-9+/=]{8,}\.")
for p in ("/config", "/profile", "/"):
chk("%-9s HTML 里没有 Cookie 密文" % p, not cipher_re.search(L.get(p)[1]))
print("== 8. 错误处理 ==")
for p in ("/api/nope", "/nope"):
@@ -254,20 +365,13 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
print("== 10. 安全:开放重定向与凭证外泄 ==")
L2 = Live(L.base) # 全新会话,避免已登录被直跳
st, html = L2.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
csrf = m.group(1) if m else ""
st, loc = L2.post_raw("/login", {"username": user, "password": pwd,
"_csrf": csrf, "next": "//evil.com"})
L2 = Live(L.base, L.timeout, L.db_path) # 全新会话,避免已登录被直跳
st, loc, _, _ = L2.login(user, pwd, nxt="//evil.com", follow=False)
chk("next=//evil.com 被拒(不出现协议相对跳转)",
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
"status=%s Location=%s" % (st, loc))
L3 = Live(L.base)
st, html = L3.get("/login")
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
st, loc = L3.post_raw("/login", {"username": user, "password": pwd,
"_csrf": m.group(1) if m else "", "next": "/records"})
L3 = Live(L.base, L.timeout, L.db_path)
st, loc, _, _ = L3.login(user, pwd, nxt="/records", follow=False)
chk("next=/records 站内路径正常放行", st == 302 and loc == "/records",
"status=%s Location=%s" % (st, loc))
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
@@ -277,6 +381,118 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
st, html = L.get("/")
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
print("== 11. 多用户:注册入口 / 验证码 / 安全响应头 ==")
L4 = Live(L.base, L.timeout, L.db_path) # 全新未登录会话
st, html = L4.get("/register")
chk("GET /register 可达", st == 200 and "注册" in html, "status=%s" % st)
chk("注册页带验证码图", "capimg" in html and "/captcha.png" in html)
chk("注册页带 CSRF 隐藏域", bool(L4.form_csrf("/register")))
st, html = L4.get("/login")
chk("登录页带验证码图", "capimg" in html and 'name="captcha"' in html)
chk("登录页带自助注册链接", "/register" in html)
# 出图:真实字节 + 禁缓存 + 确实每次都不一样
shots = {}
for purpose in ("login", "register"):
code, hdr, data = L4.raw("/captcha.png?purpose=" + purpose)
chk("GET /captcha.png?purpose=%-8s 出 PNG" % purpose,
code == 200 and data[:4] == b"\x89PNG" and len(data) > 200,
"status=%s len=%d" % (code, len(data)))
chk(" └ 禁缓存 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
chk(" └ 类型 image/png", (hdr.get("Content-Type") or "").startswith("image/png"))
shots[purpose] = data
_, _, again = L4.raw("/captcha.png?purpose=login")
chk("两次取图内容不同(不是一张静态图)", again != shots["login"])
chk("login 与 register 的图互不相同", shots["login"] != shots["register"])
# 图必须由服务端单独下发,不能把答案内联进页面
st, html = L4.get("/login")
chk("登录页没有内联 data: 图片(答案不走页面源码)",
"data:image" not in html and "base64," not in html)
# 安全响应头
code, hdr, _ = L4.raw("/login")
for name, want in (("X-Content-Type-Options", "nosniff"),
("X-Frame-Options", "DENY"),
("Referrer-Policy", "same-origin")):
chk("响应头 %-24s" % name, (hdr.get(name) or "") == want, "=%s" % hdr.get(name))
chk("响应头含 CSP 且 frame-ancestors 'none'",
"frame-ancestors 'none'" in (hdr.get("Content-Security-Policy") or ""))
code, hdr, _ = L4.raw("/captcha.png?purpose=login")
chk("/captcha 路径带 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
# 路径穿越式 purpose 必须被收敛到已知用途,而不是 500
code, _, data = L4.raw("/captcha.png?purpose=../../etc/passwd")
chk("非法 purpose 不报 500", code == 200 and data[:4] == b"\x89PNG",
"status=%s" % code)
def run_nonadmin(base: str, timeout: int, db_path: str, user: str, pwd: str) -> None:
"""普通账号的越权面(真实 HTTP 链路,--as 才跑)。
规则只有一条:普通账号**只能维护本人凭证**,其余配置 / 日志 / 用户管理
全部不可达。期望值是 403(页面)与 400(写配置)—— 不是「看得到但改不了」,
更不是「写进去但只对自己生效」。
"""
print("== 12. 普通账号越权面(--as %s) ==" % user)
L = Live(base, timeout, db_path)
st, _, _, _ = L.login(user, pwd)
chk("普通账号登录成功", st in (200, 302), "status=%s" % st)
st, html = L.get("/")
if st != 200 or "概览" not in html:
chk("普通账号登录后能看到概览", False, "status=%s(后续断言已跳过)" % st)
return
chk("普通账号登录后能看到概览", True)
chk("导航不出现「日志管理」", "日志管理" not in html)
chk("导航不出现「用户管理」", "用户管理" not in html)
# 可达页面(都只渲染本人数据)
for p, kw in [("/records", "记录"), ("/tasks", "任务"),
("/config", "配置"), ("/profile", "个人")]:
st, body = L.get(p)
chk("GET %-9s 普通账号=200" % p, st == 200 and kw in body, "status=%s" % st)
st, _ = L.get("/dashboard")
chk("GET /dashboard 普通账号=200", st == 200, "status=%s" % st)
# 不可达:日志与用户管理
for p in ("/logs", "/logs/tail?lines=10", "/users", "/api/users"):
st, _ = L.get(p)
chk("GET %-21s 普通账号=403" % p, st == 403, "status=%s" % st)
# 角色标记:页面之外还有静态页(大屏)与前端要靠它决定显隐
stj = L.jget("/api/settings")
chk("/api/settings _role=user", stj.get("_role") == "user", "%s" % stj.get("_role"))
chk("/api/settings _canEditGlobal=False", stj.get("_canEditGlobal") is False)
mf = L.jget("/api/manifest")
chk("/api/manifest role=user(大屏据此隐掉日志入口)",
mf.get("role") == "user", "%s" % mf.get("role"))
sta = L.jget("/api/status")
chk("/api/status is_admin=False", sta.get("is_admin") is False, "%s" % sta.get("is_admin"))
chk("/api/status can_edit_schedule=False", sta.get("can_edit_schedule") is False)
# 越权写:调度 / 采集参数 / 实例级键 -> 400
csrf = L.form_csrf("/config")
chk("拿到普通账号自己的 CSRF", bool(csrf))
for key, val in (("schedule_times", "23:59"), ("schedule_enabled", "0"),
("page_size", "1000"), ("ssl_verify", "0"),
("api_base", "http://evil.invalid"), ("allow_register", "0")):
st, body = L.post("/api/settings", {key: val}, csrf=csrf, as_json=True)
chk("越权 POST %-16s =400" % key, st == 400, "status=%s" % st)
chk(" └ 且点名 %s" % key, key in body)
# 本人 UA 必须写得进去;写回原值,不给对方留副作用
cur_ua = str(stj.get("user_agent") or "")
st, body = L.post("/api/settings", {"user_agent": cur_ua}, csrf=csrf, as_json=True)
chk("本人 user_agent 可写=200", st == 200, "status=%s %s" % (st, body[:100]))
# 页面只给凭证表单,采集参数与调度都渲染成只读
st, cf = L.get("/config")
chk("配置页有凭证表单", 'id="formCred"' in cf)
chk("配置页无采集参数表单", 'id="formCollect"' not in cf)
chk("配置页无实例级设置表单", 'id="formGlobal"' not in cf)
st, tk = L.get("/tasks")
chk("任务页调度只读(没有保存按钮)", "保存调度配置" not in tk)
chk("任务页标注调度仅管理员可改", "仅管理员可改" in tk)
def main() -> int:
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
@@ -285,11 +501,30 @@ def main() -> int:
ap.add_argument("-p", "--password", default="admin123", help="登录密码")
ap.add_argument("--from", dest="frm", default="2026-09-08", help="验收窗口起")
ap.add_argument("--to", dest="to", default="2026-09-14", help="验收窗口止")
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite)。"
"验证码策略为 always 时用它取答案以完成自动登录;"
"指向不存在的文件则跳过需要验证码的登录")
ap.add_argument("--timeout", type=int, default=20)
ap.add_argument("--as", dest="as_user", default=None, metavar="USER:PASS",
help="额外用一个**普通账号**跑一遍越权验收(第 12 节)。"
"不会创建/删除账号,请自己先备好一个普通账号")
a = ap.parse_args()
print("目标:%s 窗口:%s ~ %s\n" % (a.base, a.frm, a.to))
run(Live(a.base, a.timeout), a.user, a.password, a.frm, a.to)
db_path = a.db or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
if a.as_user:
if ":" not in a.as_user:
print(" [FAIL] --as 需要写成 用户名:密码")
FAILS.append("--as 参数格式")
else:
nu, np_ = a.as_user.split(":", 1)
try:
run_nonadmin(a.base, a.timeout, db_path, nu, np_)
except Exception as e: # noqa: BLE001
chk("第 12 节执行未抛异常", False, "%s: %s" % (type(e).__name__, e))
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if FAILS:
print("失败项:%s" % "、".join(FAILS))
+97 -40
查看文件
@@ -14,18 +14,21 @@
--------
| 表 | 说明 |
|---|---|
| `users` | 明示例两个账号:`admin`(管理员)与 `demo`(普通账号),各有自己的数据 |
| `usage_records` | 约 900 条合成记录,跨 30 天,含假模型名 / 假 Prompt / 偏斜的积分分布 |
| `collect_runs` | 14 条采集历史,含 ok / warn / error 三种状态 |
| `settings` | 走项目默认值(`config.DEFAULTS`),**不写入任何凭据** |
| `audit_log` | 30 条操作审计 |
| `users` | 由 `db.init_db()` 建一个管理员 |
| `collect_runs` | 采集历史,含 ok / warn / error 三种状态 |
| `settings` | 走项目默认值(`config.DEFAULTS`),凭证只写**一眼可辨的假值** |
| `audit_log` | 操作审计(按账号归属) |
刻意造两个账号,是因为「数据按账号隔离」在多用户版里是最该被截进文档的性质:
只有一个账号的话,用户管理页和「我的数据」列都看不出区别。
用法
----
# 默认写到 data/demo/(该目录在 .gitignore 内,不会误提交)
python tools/demo_data.py
# 指定目录与管理员口令,然后起服务看效果
# 指定目录与口令,然后起服务看效果
python tools/demo_data.py --out data/demo --admin-password demo123
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
@@ -61,7 +64,19 @@ CLIENTS = [("vscode", 74), ("webconsole", 18), ("sdk", 8)]
# 写进 settings 的假 Cookie。刻意用重复串,一眼就能看出不是真凭据;
# 作用只是让概览页的健康指示灯是绿的(首装状态是「缺 Cookie」告警)。
# 两个账号给不同的值,这样「各自持有自己的凭证」在截图里看得出来。
DEMO_COOKIE = "wb_demo_session=" + "deadbeef" * 15
DEMO_COOKIE_2 = "wb_demo_session=" + "cafef00d" * 15
# 第二个账号(普通用户)用的模型:刻意与管理员**不重名**,
# 这样「按账号隔离」在按模型的图上立刻可见。
MODELS_2 = [
("demo-lite", 30, 0.55),
("demo-nano", 26, 0.14),
("demo-flash", 22, 0.32),
("demo-vision", 12, 5.90),
("demo-pro", 10, 2.05),
]
PROMPTS_SHORT = [
"帮我解释一下这段代码的作用",
@@ -144,51 +159,75 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
os.environ.setdefault("WB_LOG_DIR", os.path.join(out_dir, "logs"))
os.environ["WB_DB"] = db_file
from workbuddy_portal import db # noqa: E402
from workbuddy_portal import db, security # noqa: E402
conn = db.connect()
try:
db.init_db(conn, create_admin=True, admin_user=admin_user,
admin_password=admin_password)
# 写入一个**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
# ---------- 账号 ----------
admin_row = db.user_by_name(conn, admin_user)
admin_uid = admin_row["id"]
demo_user = "demo"
if not db.user_by_name(conn, demo_user):
conn.execute(
"INSERT INTO users(username,password_hash,display_name,email,is_admin,"
"status,created_at) VALUES(?,?,?,?,0,'active',?)",
(demo_user, security.hash_password(admin_password), "演示账号",
"demo@example.invalid", db.now_str()))
db_row = db.user_by_name(conn, demo_user)
demo_uid = db_row["id"]
# 写入**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
# 而不是首装的「缺 Cookie」告警——演示与截图应当呈现「配置完成」后的样子。
# 这个值不会被任何真实服务接受,也不含任何真实凭据。
db.set_setting(conn, "cookie", DEMO_COOKIE)
# 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。
db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid)
db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid)
# 调度与采集参数是**实例级**的(v1.3.0 起普通账号只读),所以只写一次;
# 这里刻意不按账号各写一份 —— set_setting 会把全局键归到 user_id=0,
# 写两次只会后一次覆盖前一次,看起来「每人一套」其实没有。
db.set_setting(conn, "schedule_times", "09:00,17:00", 0)
rng = random.Random(seed)
now = datetime.now().replace(second=0, microsecond=0)
today0 = now.replace(hour=0, minute=0, second=0)
model_pairs = [(m, w) for m, w, _ in MODELS]
medians = {m: md for m, _, md in MODELS}
model_pairs_2 = [(m, w) for m, w, _ in MODELS_2]
medians_2 = {m: md for m, _, md in MODELS_2}
client_pairs = list(CLIENTS)
# ---------- usage_records ----------
rows = []
for d in range(days - 1, -1, -1):
day0 = today0 - timedelta(days=d)
for _ in range(rng.randint(14, 46)):
# 工作时间加权:9-19 点更密
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
minute = rng.randint(0, 59)
sec = rng.randint(0, 59)
ts = day0 + timedelta(hours=hour, minutes=minute, seconds=sec)
if ts > now:
continue
model = _weighted(rng, model_pairs)
client = _weighted(rng, client_pairs)
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
rows.append((
rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
_credits(rng, medians[model]), _prompt(rng),
stamp, stamp, stamp,
))
# ---------- usage_records(两个账号各生成一份)----------
def _gen_records(uid, pairs, med, lo=14, hi=46):
rows = []
for d in range(days - 1, -1, -1):
day0 = today0 - timedelta(days=d)
for _ in range(rng.randint(lo, hi)):
# 工作时间加权:9-19 点更密
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
ts = day0 + timedelta(hours=hour, minutes=rng.randint(0, 59),
seconds=rng.randint(0, 59))
if ts > now:
continue
model = _weighted(rng, pairs)
client = _weighted(rng, client_pairs)
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
rows.append((
uid, rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
_credits(rng, med[model]), _prompt(rng), stamp, stamp, stamp,
))
return rows
rows = _gen_records(admin_uid, model_pairs, medians)
# 普通账号只给大约三分之二的量:列表里一眼能分出主次
rows += _gen_records(demo_uid, model_pairs_2, medians_2, lo=9, hi=31)
conn.execute("BEGIN")
conn.executemany(
"INSERT OR REPLACE INTO usage_records"
"(request_id,ts,day,hour,model,client,credits,prompt,"
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
"(user_id,request_id,ts,day,hour,model,client,credits,prompt,"
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", rows)
conn.execute("COMMIT")
# ---------- collect_runs ----------
@@ -212,7 +251,7 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
fetched = dup = added = 0
trigger = "schedule" if i % 3 else "manual"
runs.append((
trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
admin_uid, trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
(started + timedelta(milliseconds=rng.randint(180, 1400))
).strftime("%Y-%m-%d %H:%M:%S"),
rng.randint(180, 1400),
@@ -221,11 +260,20 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
fetched, added, dup, total, 0, exit_code, msg,
"[%s] %s" % (status, msg),
))
# 普通账号也给两条,让「日志只显示自己的」在截图里成立
for k, (st, msg) in enumerate((("ok", "新增 6 条,重复 4 条,存档共 192 条"),
("ok", "新增 3 条,重复 5 条,存档共 186 条"))):
at = now - timedelta(hours=5 + k * 9)
runs.append((demo_uid, "schedule", st, at.strftime("%Y-%m-%d %H:%M:%S"),
at.strftime("%Y-%m-%d %H:%M:%S"), 420,
(at - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
at.strftime("%Y-%m-%d %H:%M:%S"), 10 - k, 6 - k * 3, 4, 192, 0, 0,
msg, "[%s] %s" % (st, msg)))
conn.execute("BEGIN")
conn.executemany(
"INSERT INTO collect_runs(trigger,status,started_at,finished_at,duration_ms,"
"INSERT INTO collect_runs(user_id,trigger,status,started_at,finished_at,duration_ms,"
"win_from,win_to,fetched,added,dup,total,conflicts,exit_code,message,detail)"
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
conn.execute("COMMIT")
# ---------- audit_log ----------
@@ -235,18 +283,21 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
actions = [
("login", "登录成功", "192.0.2.10"),
("login", "登录成功", "192.0.2.10"),
("settings", "修改:调度时刻 09:00,17:00", "192.0.2.10"),
("settings", "修改:分页大小 200", "192.0.2.10"),
("settings", "修改:schedule_times", "192.0.2.10"),
("settings", "修改:page_size", "192.0.2.10"),
("maintenance.count", "存档当前 %d 条记录" % total, "192.0.2.10"),
("settings_rejected", "参数错误:page_size 必须是数字(条/页)", "192.0.2.10"),
]
for i in range(30):
act, detail, ip = actions[i % len(actions)]
at = now - timedelta(hours=i * 3 + rng.randint(0, 2))
audits.append((at.strftime("%Y-%m-%d %H:%M:%S"), admin_user, act, detail, ip))
uid = admin_uid if i % 3 else demo_uid
actor = admin_user if i % 3 else demo_user
audits.append((uid, at.strftime("%Y-%m-%d %H:%M:%S"), actor, act, detail, ip))
conn.execute("BEGIN")
conn.executemany(
"INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)", audits)
"INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(?,?,?,?,?,?)", audits)
conn.execute("COMMIT")
# 统计一下,便于打印
@@ -255,7 +306,13 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
d = conn.execute("SELECT COUNT(DISTINCT day) FROM usage_records").fetchone()[0]
print("示例库:%s" % db_file)
print(" 记录 %d 条 / 积分 %s / 覆盖 %d 天" % (n, c, d))
print(" 管理员 %s,口令 %s" % (admin_user, admin_password))
for r in conn.execute(
"SELECT u.id,u.username,u.is_admin,"
" (SELECT COUNT(*) FROM usage_records x WHERE x.user_id=u.id) AS n"
" FROM users u ORDER BY u.id"):
print(" 账号 #%s %-8s %-6s %d 条"
% (r["id"], r["username"], "管理员" if r["is_admin"] else "普通", r["n"]))
print(" 口令都是 %s(仅供本地演示)" % admin_password)
print(" 该目录在 .gitignore 内,不会被提交")
finally:
conn.close()
+141 -34
查看文件
@@ -6,33 +6,55 @@
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
用法:
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
python tools/shots.py --base http://127.0.0.1:8849
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
python tools/shots.py --base http://127.0.0.1:8849 --db data/demo/usage.sqlite
python tools/shots.py --full # 整页长图(默认只截首屏)
产物:data/shots/*.png(已被 .gitignore 之外的目录,可直接删)。
产物:data/shots/*.png(该目录在 .gitignore 内,可直接删)。
为什么不用 headless chrome 直出:本项目的页面都要登录态,
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
验证码:默认策略是 always,所以本脚本会**从本地库里取答案**(取的是会话里的
captcha id,答案只存在于服务端),这样自动化能跨过验证码这一关。
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sqlite3
import sys
import urllib.parse
import zlib
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
PAGES = [
("login", "/login", "登录页"),
("overview", "/", "概览"),
("records", "/records", "数据明细"),
("tasks", "/tasks", "任务管理"),
("config", "/config", "配置管理"),
("logs", "/logs", "日志管理"),
("users", "/users", "用户管理"),
("dashboard", "/dashboard", "用量大屏"),
# 文件名刻意与原有编号保持一致(docs/USER-GUIDE.md 里就是按这些名字引用的),
# 新增页面排在后面,避免为了两张新图去改一堆文档链接。
CAPTURES = [
# (文件名, 路径, 标签, 是否需登录)
("00-login.png", "/login", "登录页 (含图形验证码)", False),
("09-register.png", "/register", "注册页", False),
("01-overview.png", "/", "概览", True),
("02-records.png", "/records", "数据明细", True),
("03-tasks.png", "/tasks", "任务管理", True),
("04-config.png", "/config", "配置管理", True),
("05-logs.png", "/logs", "日志管理", True),
("06-users.png", "/users", "用户管理", True),
("07-dashboard.png", "/dashboard", "用量大屏", True),
("10-profile.png", "/profile", "个人中心", True),
]
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角,
# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。
USER_CAPTURES = [
("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"),
("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"),
]
@@ -61,18 +83,62 @@ def _find_browser() -> str | None:
return None
def _session_payload(ctx) -> dict:
"""解出 Flask 会话 cookie 里的载荷,只为拿 captcha 的 id。
Flask 会话是「签名 + base64,不加密」的,所以这里面能读出内容 ——
正因如此,验证码答案绝不能放进去(本项目只放一个随机 id)。
"""
for c in ctx.cookies():
if not c.get("name", "").startswith("workbuddy_portal_sid"):
continue
seg = urllib.parse.unquote(c.get("value", "")).split(".")[0]
seg += "=" * (-len(seg) % 4)
try:
raw = base64.urlsafe_b64decode(seg)
try:
raw = zlib.decompress(raw)
except zlib.error:
pass
return json.loads(raw.decode("utf-8"))
except Exception: # noqa: BLE001
return {}
return {}
def _captcha_answer(ctx, db_path: str, purpose: str) -> str | None:
cid = _session_payload(ctx).get("cap_" + purpose)
if not cid or not db_path or not os.path.exists(db_path):
return None
try:
con = sqlite3.connect(db_path)
try:
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
finally:
con.close()
except sqlite3.Error:
return None
return row[0] if row else None
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--base", default="http://127.0.0.1:8849")
ap.add_argument("-u", "--user", default="admin")
ap.add_argument("-p", "--password", default="admin123")
ap.add_argument("--user2", default="demo",
help="普通账号用户名(截只读视角用;设为空则跳过)")
ap.add_argument("--password2", default="admin123")
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
ap.add_argument("--db", default=None,
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
ap.add_argument("--full", action="store_true", help="截整页长图")
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
ap.add_argument("--width", type=int, default=1440)
ap.add_argument("--height", type=int, default=900)
a = ap.parse_args()
db_path = a.db or os.path.join(BASE, "data", "usage.sqlite")
from playwright.sync_api import sync_playwright
exe = a.browser or _find_browser()
@@ -93,33 +159,51 @@ def main() -> int:
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
# 1) 先截未登录的登录页
page.goto(a.base + "/login", wait_until="networkidle")
page.screenshot(path=os.path.join(a.out, "00-login.png"), full_page=a.full)
print("[ok] 00-login.png")
def shoot(name, path, label, pg=None):
pg = pg or page
errors.clear()
pg.goto(a.base + path, wait_until="networkidle")
pg.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
pg.screenshot(path=os.path.join(a.out, name), full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %-22s %s%s" % (name, label,
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
# 2) 登录
page.fill('input[name=username]', a.user)
page.fill('input[name=password]', a.password)
page.click('button[type=submit]')
page.wait_for_load_state("networkidle")
if "/login" in page.url:
def login(pg, ctx, user, password):
"""登录并跨过验证码(策略为 always 时从本地库取答案)。"""
pg.goto(a.base + "/login", wait_until="networkidle")
pg.fill('input[name=username]', user)
pg.fill('input[name=password]', password)
if pg.query_selector('input[name=captcha]'):
ans = _captcha_answer(ctx, db_path, "login")
if not ans:
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
return False
pg.fill('input[name=captcha]', ans)
print("[ok] 已用库里的答案通过验证码(%s)" % user)
pg.click('button[type=submit]')
pg.wait_for_load_state("networkidle")
return "/login" not in pg.url
# 1) 未登录的两页
for name, path, label, need_auth in CAPTURES:
if need_auth:
break
shoot(name, path, label)
# 2) 以管理员登录(策略为 always 时自动解验证码)
if not login(page, ctx, a.user, a.password):
print("[FAIL] 登录失败,后续截图无意义")
br.close()
return 1
# 3) 逐页截图
for i, (slug, path, label) in enumerate(PAGES[1:], start=1):
errors.clear()
page.goto(a.base + path, wait_until="networkidle")
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
page.screenshot(path=os.path.join(a.out, "%02d-%s.png" % (i, slug)),
full_page=a.full)
js_err = [e for e in errors if "favicon" not in e.lower()]
flag = "" if not js_err else " [JS错误] " + " | ".join(js_err[:3])
if js_err:
problems.append("%s: %s" % (label, js_err[:3]))
print("[ok] %02d-%s.png %s%s" % (i, slug, label, flag))
# 3) 登录后的页面
for name, path, label, need_auth in CAPTURES:
if not need_auth:
continue
shoot(name, path, label)
# 4) 大屏页再点几个交互,确认控件联动不炸
page.goto(a.base + "/dashboard", wait_until="networkidle")
@@ -139,6 +223,29 @@ def main() -> int:
problems.append("大屏交互: %s" % js_err[:2])
break
# 5) 换一个干净 context,用普通账号再跑一趟只读视角
if a.user2:
ctx2 = br.new_context(viewport={"width": a.width, "height": a.height},
device_scale_factor=2, locale="zh-CN")
page2 = ctx2.new_page()
page2.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page2.on("pageerror", lambda e: errors.append(str(e)))
if not login(page2, ctx2, a.user2, a.password2):
print("[warn] 普通账号 %s 登录失败,跳过只读视角截图" % a.user2)
problems.append("普通账号 %s 登录失败" % a.user2)
else:
for name, path, label in USER_CAPTURES:
shoot(name, path, label, pg=page2)
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
for probe in ("/logs", "/logs/tail", "/users"):
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
code = r.status if r else 0
ok = code in (403, 401)
print("[%s] 越权面 %-12s -> %s" % ("ok" if ok else "!!", probe, code))
if not ok:
problems.append("越权面未关死:%s 返回 %s" % (probe, code))
ctx2.close()
br.close()
print("\n截图目录:%s" % a.out)
+432 -56
查看文件
@@ -11,11 +11,21 @@
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见下 REGRESSIONS)
4. CSV 导出可被标准 csv 解析、列数一致
5. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
3. 历史缺陷防回归(见 5. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / **普通账号的越权面**(4. 与 4b.)
5. CSV 导出可被标准 csv 解析、列数一致
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
权限模型(改断言前先读这一行):
普通账号**只能写** `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
调度、采集参数、接口地址、注册策略全部只有管理员能写,且一律存在实例级
`user_id=0`。所以「越权写」的期望结果是 **400**,而不是「写进去但看不到」。
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
不会改动任何用量数据。
用法:
cd workbuddy-portal
@@ -28,7 +38,9 @@ import csv
import io
import json
import os
import random
import re
import string
import sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -57,14 +69,19 @@ def note(msg: str) -> None:
print(" [note] %s" % msg)
def login(cli, admin=True):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。"""
def login(cli, uid: int, csrf: str = "smoke-csrf-token"):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。
只有 `uid` 是真正生效的键 —— `security.current_user()` 每请求回查
users 表(这样做是为了「停用账号立即失效」),所以 `uname/dname/adm`
只是写给自己看的标记,改不了权限。
"""
with cli.session_transaction() as s:
s["uid"] = 1
s["uname"] = "admin" if admin else "viewer"
s["dname"] = "管理员" if admin else "只读账号"
s["adm"] = 1 if admin else 0
s["_csrf"] = "smoke-csrf-token"
s["uid"] = uid
s["uname"] = "smoke-%s" % uid
s["dname"] = "smoke"
s["adm"] = 0
s["_csrf"] = csrf
def page(cli, path, method="GET", **kw):
@@ -72,36 +89,70 @@ def page(cli, path, method="GET", **kw):
return r.status_code, r.get_data(as_text=True)
def _rand(n=8):
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def run() -> None:
from workbuddy_portal import create_app, db, query
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==")
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
chk("create_app 成功", app is not None)
chk("路由数量 >= 35", n_routes >= 35, "routes=%d" % n_routes)
chk("路由数量 >= 40", n_routes >= 40, "routes=%d" % n_routes)
# 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect()
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone()
if admin_row is None:
print("\n[FATAL] 库里没有启用的管理员账号,先跑 python manage.py init")
return
ADMIN = admin_row["id"]
# 实例级配置快照:整轮跑完必须一条不少。
# 历史缺陷:清理语句写成 `user_id NOT IN (SELECT id FROM users)`,会把
# user_id=0(实例级)当成孤儿一起删掉 —— 表现为「跑一次 smoke,全实例的
# 调度/采集参数被重置」,而且不报任何错。所以这里前后各取一次快照。
inst_before = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("实例级配置非空(否则下面那条断言会空转)", len(inst_before) > 0,
"keys=%d" % len(inst_before))
chk("实例级不含凭证类键(cookie/user_agent 恒为个人级)",
not (inst_before & config.USER_EDITABLE_KEYS),
"混入=%s" % sorted(inst_before & config.USER_EDITABLE_KEYS))
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users"):
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
# 只认 POST 的接口:GET 应当 405(而不是落到 401 或被 GET 直接执行)
# 未登录的 POST 会被 before_request 里的 CSRF 先拦下(400)——
# 这比先鉴权更好:没有会话就不该被允许碰任何写接口。
for p in ("/api/profile", "/api/captcha"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=405" % p, st == 405, "status=%s" % st)
st, _ = page(cli, p, method="POST")
chk("POST %-13s 无 CSRF=400" % p, st == 400, "status=%s" % st)
st, html = page(cli, "/login")
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
chk("登录页含验证码图", "capimg" in html and 'name="captcha"' in html)
chk("登录页含自助注册入口", "/register" in html)
# ---------------- 2. 管理员:全页面渲染 ----------------
print("== 2. 管理员:全页面渲染 ==")
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
pages = [
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
("/dashboard", "<html"),
("/profile", "个人中心"), ("/dashboard", "<html"),
]
for p, kw in pages:
st, html = page(cli, p)
@@ -112,24 +163,44 @@ def run() -> None:
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
st, html = page(cli, "/users")
chk("用户管理页列出账号", 'data-uid=' in html, "含行内编辑按钮")
chk("用户管理页列出账号", 'data-uid=' in html)
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
chk("用户管理页含审计表", "用户操作审计" in html)
chk("用户管理页含账号操作审计", "账号操作审计" in html)
chk("用户管理页含状态列", "status" in html and "停用" in html)
chk("用户管理页含邮箱列", "邮箱" in html)
# 配置页的维护按钮 + 大屏回后台入口
st, cfg = page(cli, "/config")
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
chk("配置页含实例级设置区", "仅管理员可改" in cfg)
chk("配置页显示凭证状态而非明文", "cookie_hint" in cfg or "字符" in cfg)
st, rec = page(cli, "/records")
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
st, pf = page(cli, "/profile")
chk("个人中心含改密表单", "/api/password" in pf or "password" in pf)
chk("个人中心说明凭证归属本人", "本人凭证" in pf or "我的 Cookie" in pf)
# 防回归:base.html 里曾用 {% set me = current_user() %},把子模板的 me
# 覆盖掉了 —— current_user() 只有 id/username/display_name/is_admin,
# 于是「注册于」渲染成空。变量已改名 cur,这里把两处都盯住。
lead = re.search(r'<p class="lead">(.*?)</p>', pf, re.S)
lead_txt = " ".join(lead.group(1).split()) if lead else ""
chk("个人中心「注册于」有真实时间",
bool(re.search(r"注册于\s+\d{4}-\d{2}-\d{2}", lead_txt)), lead_txt[:80])
chk("个人中心「最近登录」不是空占位",
bool(re.search(r"最近登录\s+\S", lead_txt)), lead_txt[:80])
chk("base.html 未再用 me 作局部变量(防覆盖子模板)",
"set me = " not in open(os.path.join(
BASE, "workbuddy_portal", "web", "templates", "base.html"),
encoding="utf-8").read())
# ---------------- 2b. 静态资源引用可解析 ----------------
print("== 2b. 页面引用的静态资源全部可达 ==")
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
with app.test_client() as cli:
login(cli, admin=True)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/dashboard"):
login(cli, ADMIN)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users",
"/profile", "/dashboard"):
_, html = page(cli, p)
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
# 不剥会把示例当真实引用误报。
@@ -150,25 +221,327 @@ def run() -> None:
chk("%-11s 资源引用全部 200" % p, not bad,
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
# ---------------- 3. 非管理员:权限边界 ----------------
print("== 3. 非管理员:/users 必须 403,导航不出现该入口 ==")
with app.test_client() as cli:
login(cli, admin=False)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
for p in ("/", "/records", "/tasks", "/logs"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# ---------------- 3. 多用户:隔离 / 保密 / 注册与验证码 ----------------
print("== 3. 多用户:数据隔离 / 凭证保密 / 注册与验证码 ==")
viewer = "smoke_v_%s" % _rand()
regged = "smoke_r_%s" % _rand()
created: list[str] = [viewer]
saved_ua_inst = None
# ---------------- 4. 历史缺陷防回归 ----------------
print("== 4. 历史缺陷防回归 ==")
def _mk_user(name):
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
"status,created_at) VALUES(?,?,?,0,'active',?)",
(name, security.hash_password("Smoke-Pass1"), "冒烟账号", db.now_str()))
return conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()["id"]
try:
VIEWER = _mk_user(viewer)
# ① 凭证密文入库
row = conn.execute("SELECT value FROM settings WHERE key='cookie' AND user_id=?",
(ADMIN,)).fetchone()
if row and row["value"]:
chk("① Cookie 以密文入库(v1. 前缀)", crypto.is_encrypted(row["value"]),
"head=%s" % row["value"][:12])
chk("① 密文不含明文片段",
crypto.is_encrypted(row["value"]) and ";" not in row["value"][:4])
plain = db.get_secret(conn, "cookie", ADMIN)
chk("① 解回来长度合理(>100 字符)", len(plain) > 100, "chars=%d" % len(plain))
else:
note("库里没有 Cookie,跳过密文断言")
# ② 凭证绝不跨账号回落
chk("② NO_FALLBACK_KEYS 含 cookie/user_agent",
{"cookie", "user_agent"} <= db.NO_FALLBACK_KEYS)
chk("② 新账号读不到别人的 Cookie", db.get_secret(conn, "cookie", VIEWER) == "")
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
# 注意:get_setting 在没有行时会回落到 DEFAULTS,所以「还原是否成功」
# 要拿**行为**比(读出来一样),而不是拿「行在不在」比 —— 这两件事
# 在实例级是分开的,混起来会让断言永远失败。
before_ua = db.get_setting(conn, "user_agent", "", 0)
had_row = conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None
saved_ua_inst = before_ua if had_row else None
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
chk("② 实例级放哨兵后,新账号仍看不到它",
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
db.get_setting(conn, "user_agent", "", 0) == sentinel)
# 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键
# (v1.3.0 起 cookie / user_agent 恒为个人级),写回空串只会留下
# 一个多余行,下次跑就会让「实例级不含凭证键」的断言失败。
if had_row:
db.set_setting(conn, "user_agent", before_ua, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
chk("② 已还原实例级 UA(读出来与放哨兵前一致)",
db.get_setting(conn, "user_agent", "", 0) == before_ua)
chk("② 还原后实例级不留 user_agent 行(原本有则保留)",
(conn.execute("SELECT 1 FROM settings WHERE user_id=0 AND key='user_agent'"
).fetchone() is not None) == had_row)
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
chk("② 普通配置仍回落实例级",
db.get_setting(conn, "page_size", None, VIEWER) ==
db.get_setting(conn, "page_size", None, 0))
# ③ /api/settings 只给掩码,绝不给明文
with app.test_client() as cli:
login(cli, ADMIN)
st, body = page(cli, "/api/settings")
j = json.loads(body)
chk("③ /api/settings 200", st == 200, "status=%s" % st)
plain = db.get_secret(conn, "cookie", ADMIN)
chk("③ 响应体不含 Cookie 明文", not plain or plain not in body)
chk("③ cookie 字段被置空", not (j.get("cookie") or "").strip())
chk("③ 只给 cookie_hint 掩码", "cookie_hint" in j and "cookie_broken" in j)
chk("③ 标注实例级键清单", isinstance(j.get("_globalKeys"), list) and j["_globalKeys"])
chk("③ 管理员 _canEditGlobal=True", j.get("_canEditGlobal") is True)
chk("③ 无 slot:* 内部键", "slot:" not in body)
# ④ 验证码:不落 session、一次性、出图禁缓存
with app.test_client() as cli:
r = cli.get("/captcha.png?purpose=login")
chk("④ /captcha.png=200", r.status_code == 200, "status=%s" % r.status_code)
chk("④ 是 PNG 字节流",
r.headers.get("Content-Type", "").startswith("image/png")
and r.get_data()[:8] == b"\x89PNG\r\n\x1a\n")
chk("④ 出图禁缓存", "no-store" in r.headers.get("Cache-Control", ""))
with cli.session_transaction() as s:
cid = s.get("cap_login")
chk("④ 会话里只存验证码 id", bool(cid), "id=%s" % (cid or "无"))
ans = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("④ 答案只存在服务端 captchas 表",
ans is not None and len(ans["answer"]) >= 4 and ans["purpose"] == "login")
if ans:
st, html = page(cli, "/login")
chk("④ 页面 HTML 里搜不到答案", ans["answer"] not in html)
chk("④ 页面 JS 里也搜不到会话密钥", cid not in html)
# 一次性:同一个 id 用两次,第二次必须失败
if ans:
chk("④ 首次校验通过",
captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 同 id 二次校验失败(已消费)",
not captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 消费后记录已删除",
conn.execute("SELECT COUNT(*) FROM captchas WHERE id=?",
(cid,)).fetchone()[0] == 0)
# ⑤ 自助注册全链路(取答案 -> POST /register -> 账号可用)
with app.test_client() as cli:
cli.get("/register")
# 验证码图是浏览器去取的,test_client 不会自动加载 <img>,
# 所以这里显式打一次 —— 这一步正是「注册页有没有发挑战」的验证
r = cli.get("/captcha.png?purpose=register")
chk("⑤ 注册页的验证码接口可用", r.status_code == 200
and r.get_data()[:4] == b"\x89PNG", "status=%s" % r.status_code)
with cli.session_transaction() as s:
cid = s.get("cap_register")
# 这个客户端没有走 login() 注入固定 token,所以要取真实值;
# 顺手也证明了 /register 的 CSRF 校验确实在生效
csrf = s.get("_csrf")
a2 = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("⑤ 注册用的挑战落在 register 用途下",
a2 is not None and a2["purpose"] == "register")
if a2:
st, _ = page(cli, "/register", method="POST", data={
"username": regged, "display_name": "冒烟注册", "email": "",
"password": "Smoke-Pass1", "password2": "Smoke-Pass1",
"captcha": a2["answer"]},
headers={"X-CSRF-Token": csrf or ""})
chk("⑤ 注册成功=302", st == 302, "status=%s" % st)
created.append(regged)
u = conn.execute("SELECT id,is_admin,status,display_name,last_login_ip"
" FROM users WHERE username=?", (regged,)).fetchone()
chk("⑤ 建出的是普通账号",
u is not None and u["is_admin"] == 0 and u["status"] == "active")
chk("⑤ 注册即登录(会话已建立)",
u is not None and u["id"] == db.user_by_name(conn, regged)["id"])
# 缺 CSRF 必须 400
st, _ = page(cli, "/register", method="POST", data={"username": "x" * 3})
chk("⑤ 注册缺 CSRF=400", st == 400, "status=%s" % st)
# ⑥ 权限边界:普通账号改不了实例级配置
with app.test_client() as cli:
login(cli, VIEWER)
st, j = page(cli, "/api/settings")
chk("⑥ 非管理员 _canEditGlobal=False", json.loads(j).get("_canEditGlobal") is False)
evil = "http://evil.invalid"
st, _ = page(cli, "/api/settings", method="POST", json={"api_base": evil},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 非管理员改实例级配置=400", st == 400, "status=%s" % st)
chk("⑥ 且确实没写进去",
db.get_setting(conn, "api_base", "", VIEWER) != evil
and db.get_setting(conn, "api_base", "", 0) != evil)
# ⑦ 数据隔离:所有查询函数都必须显式带 uid
try:
query.daily(conn)
chk("⑦ query.daily 漏传 uid 会报错", False, "居然没报错")
except TypeError:
chk("⑦ query.daily 漏传 uid 会报错", True)
d_admin = query.daily(conn, ADMIN)
d_viewer = query.daily(conn, VIEWER)
chk("⑦ 不同账号的 daily 互不相同",
not d_admin or d_viewer != d_admin or len(d_viewer) == 0)
chk("⑦ 新账号 totals 为空", query.totals(conn, VIEWER)["records"] == 0)
t_admin = query.totals(conn, ADMIN)
chk("⑦ 管理员 totals 有数据", t_admin["records"] > 0, "records=%d" % t_admin["records"])
chk("⑦ totals(uid=0) 不含任何人的数据",
query.totals(conn, 0)["records"] == 0)
finally:
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)。
# 原本实例级没有这一行时,**删掉**而不是写回空串 —— 见第 ② 条断言。
if saved_ua_inst is not None:
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
else:
conn.execute("DELETE FROM settings WHERE user_id=0 AND key='user_agent'")
for name in created:
conn.execute("DELETE FROM users WHERE username=?", (name,))
# 注意 `user_id<>0` 不能省:user_id=0 是**实例级配置**(调度、采集参数、
# 接口地址、注册策略都在那里),它不属于任何账号,所以
# `NOT IN (SELECT id FROM users)` 会把它当孤儿一起删掉 ——
# 表现成「跑一次 smoke,全实例的配置被重置」,且不报任何错。
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# 确认清理干净
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
).fetchone()[0]
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
# ---------------- 4. 普通账号的权限边界 ----------------
# 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent,
# 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled
# 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。
print("== 4. 非管理员:越权面必须全部关死 ==")
viewer2 = "smoke_w_%s" % _rand()
try:
V2 = _mk_user(viewer2)
with app.test_client() as cli:
login(cli, V2)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
# ④ 日志是**实例级**运行信息(含数据库路径 / 账号名 / 来源 IP),
# 普通账号整页 403 —— 不是「只看到自己那份」。
st, _ = page(cli, "/logs")
chk("GET /logs 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/logs/tail?lines=10")
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
# ⑤ 其余页面(都只渲染本人数据)必须照常能开
for p in ("/", "/dashboard", "/records", "/tasks", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-11s 非管理员=200" % p, st == 200, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("概览导航不含「日志管理」", "日志管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
# ⑥ 越权写:调度 / 采集参数 / 实例级键,逐个试,全部必须 400
keep_times = db.get_setting(conn, "schedule_times", "", 0)
for key, val in (("schedule_times", "23:59"),
("schedule_enabled", "0"),
("catch_up", "0"),
("page_size", "1000"),
("timeout", "300"),
("ssl_verify", "0"),
("max_prompt", "0"),
("api_base", "http://evil.invalid"),
("allow_register", "0")):
st, body = page(cli, "/api/settings", method="POST", json={key: val},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 越权写 %-16s =400" % key, st == 400, "status=%s" % st)
chk(" └ 报错里点名 %s" % key, key in body)
chk("⑥ 越权尝试确实没落库(schedule_times 未变)",
db.get_setting(conn, "schedule_times", "", 0) == keep_times)
chk("⑥ 实例级 api_base 未被改写",
"evil" not in db.get_setting(conn, "api_base", "", 0))
# ⑦ 但本人凭证必须写得进去(否则普通账号根本没法采集)
st, _ = page(cli, "/api/settings", method="POST",
json={"user_agent": "SMOKE-VIEWER-UA/1.0"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑦ 普通账号写本人 user_agent=200", st == 200, "status=%s" % st)
chk("⑦ 且只写进了自己名下",
db.get_setting(conn, "user_agent", "", V2) == "SMOKE-VIEWER-UA/1.0")
# ⑧ 任务页给普通账号渲染的是只读表,且没有「保存调度配置」按钮
st, tk = page(cli, "/tasks")
chk("⑧ 任务页标注调度只读", "仅管理员可改" in tk)
chk("⑧ 任务页无调度保存按钮", "保存调度配置" not in tk)
# ⑨ 配置页对普通账号只给凭证表单,采集参数渲染成只读表
st, cf = page(cli, "/config")
chk("⑨ 配置页有凭证表单", 'id="formCred"' in cf)
chk("⑨ 配置页无采集参数表单", 'id="formCollect"' not in cf)
chk("⑨ 配置页无实例级设置表单", 'id="formGlobal"' not in cf)
chk("⑨ 配置页说明范围", "唯一可以修改" in cf)
# ⑩ /api/status 的角色字段(大屏与前端靠它显隐管理员入口)
st, sj = page(cli, "/api/status")
chk("⑩ GET /api/status 普通账号=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("⑩ is_admin=False", j.get("is_admin") is False, "%s" % j.get("is_admin"))
chk("⑩ can_edit_schedule=False", j.get("can_edit_schedule") is False)
chk("⑩ can_view_logs=False", j.get("can_view_logs") is False)
finally:
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
# `user_id<>0` 是必须的:0 是实例级配置,不能当孤儿清理(见第 3 节的说明)
conn.execute("DELETE FROM settings WHERE user_id<>0"
" AND user_id NOT IN (SELECT id FROM users)")
# ---------------- 4b. 全局键的落库位置 ----------------
# 这一节盯的是「管理员改了但只有自己生效」这类**静默** bug:
# 全局键若被写进管理员的 user_id,其它账号读取时会回落到 DEFAULTS,
# 表现成「设置莫名其妙不生效」,而且不报任何错。
print("== 4b. 全局键必须落在实例级 user_id=0 ==")
chk("schedule_times 是全局键", config.is_global_key("schedule_times"))
chk("page_size 是全局键", config.is_global_key("page_size"))
chk("cookie 不是全局键(本人凭证)", not config.is_global_key("cookie"))
chk("slot:* 仍是个人级(每人各自记今天跑过没)",
not config.is_global_key("slot:09:00"))
chk("普通账号只被允许写 cookie/user_agent",
config.writable_by("cookie", False) and config.writable_by("user_agent", False)
and not config.writable_by("schedule_times", False)
and not config.writable_by("page_size", False))
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
same = db.get_setting(conn, "schedule_times", "", 0)
st, _ = page(cli, "/api/settings", method="POST",
json={"schedule_times": same or "09:00"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("管理员写 schedule_times=200", st == 200, "status=%s" % st)
chk("只存在实例级那一份",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id=0"
" AND key='schedule_times'").fetchone()[0] == 1)
chk("管理员名下不留个人级副本(否则别人读不到)",
conn.execute("SELECT COUNT(*) FROM settings WHERE user_id<>0"
" AND key='schedule_times'").fetchone()[0] == 0)
# /api/status 是大屏与前端判断角色用的接口,必须真的能开且角色正确
# (它曾经因为改字段时引用了未定义的变量而 500,两层测试都没覆盖到)
st, sj = page(cli, "/api/status")
chk("GET /api/status 管理员=200", st == 200, "status=%s" % st)
if st == 200:
j = json.loads(sj)
chk("└ is_admin=True", j.get("is_admin") is True, "%s" % j.get("is_admin"))
chk("└ can_edit_schedule=True", j.get("can_edit_schedule") is True)
chk("└ 凭证只回「有没有 / 多少字符」",
all(k in j for k in ("cookie_set", "cookie_chars", "cookie_broken"))
and "cookie" not in j)
# 收尾自检:实例级配置必须还在(对照开头那份快照)
inst_after = {r["key"] for r in conn.execute("SELECT key FROM settings WHERE user_id=0")}
chk("跑完整轮 smoke 后,实例级配置一条不少", inst_before <= inst_after,
"丢失=%s" % sorted(inst_before - inst_after))
conn.close()
# ---------------- 5. 历史缺陷防回归 ----------------
print("== 5. 历史缺陷防回归 ==")
with app.test_client() as cli:
login(cli, ADMIN)
# ① 非法日期曾 500
st, body = page(cli, "/api/summary?from=abc&to=def")
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
@@ -222,8 +595,8 @@ def run() -> None:
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
# ⑪ 非管理员调用户管理 API
st, _ = page(cli, "/api/users/1/delete", method="POST", json={},
# ⑪ 管理员不能删自己
st, _ = page(cli, "/api/users/%d/delete" % ADMIN, method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
# ⑫ CSRF 缺失必须 400
@@ -251,10 +624,10 @@ def run() -> None:
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
"命中=%d 混入=%s" % (len(tags), others))
# ---------------- 5. 数据自洽 ----------------
print("== 5. 数据自洽(只读) ==")
# ---------------- 6. 数据自洽 ----------------
print("== 6. 数据自洽(只读) ==")
with app.test_client() as cli:
login(cli, admin=True)
login(cli, ADMIN)
mf = json.loads(page(cli, "/api/manifest")[1])
src = (mf.get("sources") or [{}])[0]
chk("manifest 存档条数 == 数据源条数",
@@ -267,29 +640,32 @@ def run() -> None:
chk("summary 全量 credits 自洽",
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
d = query.daily(db.get_db())
d = query.daily(db.get_db(), ADMIN)
chk("daily 逐日积分求和 == 存档总额",
abs(round(sum(float(x["c"]) for x in d), 2)
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
chk("daily 逐日 h[24] 求和 == 当日积分",
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
note("存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
note("管理员存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
# ---------------- 6. class 名与 CSS 选择器对账 ----------------
print("== 6. 页面 class 与 app.css 选择器对账 ==")
# ---------------- 7. class 名与 CSS 选择器对账 ----------------
print("== 7. 页面 class 与 app.css 选择器对账 ==")
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
encoding="utf-8").read()
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
anon = ("/login", "/register")
auth = ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile")
used: set[str] = set()
for p in anon:
with app.test_client() as c2:
html = page(c2, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
with app.test_client() as cli:
login(cli, admin=True)
used: set[str] = set()
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/login"):
if p == "/login":
with app.test_client() as c2:
html = page(c2, p)[1]
else:
html = page(cli, p)[1]
login(cli, ADMIN)
for p in auth:
html = page(cli, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
# 允许的无样式类:JS 钩子、第三方/语义标记
+7 -1
查看文件
@@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request
from . import config, db, security
__version__ = "1.1.0"
__version__ = "1.3.0"
PROJECT_NAME = config.PROJECT_NAME
@@ -51,8 +51,14 @@ def create_app(start_scheduler=True, do_init_db=True, **overrides):
app.config.update(
SECRET_KEY=config.secret_key(),
PERMANENT_SESSION_LIFETIME=timedelta(hours=config.SESSION_HOURS),
# ---- 会话 Cookie 加固 ----
# HttpOnly:JS 读不到(XSS 也别想直接偷走会话)
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
# Secure:仅 HTTPS 下发。纯局域网 HTTP 部署必须留 0,否则浏览器
# 根本不会回传 Cookie,表现为「刚登录完又被弹回登录页」。
SESSION_COOKIE_SECURE=config.COOKIE_SECURE,
SESSION_COOKIE_PATH="/",
SESSION_COOKIE_NAME="workbuddy_portal_sid",
MAX_CONTENT_LENGTH=4 * 1024 * 1024,
TEMPLATES_AUTO_RELOAD=True,
+236
查看文件
@@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""图形验证码(自托管,零第三方依赖)。
设计要点
--------
1. **答案只存在服务端**。下发到浏览器的是一个随机 `captcha_id`,它本身
不含任何信息。之所以不把答案放进 Flask session:Flask 的 session 是
**签名而非加密**的(base64 + HMAC),客户端 base64 解开就能读到明文——
把答案放进去等于把答案直接送给机器人。
2. **一次性**。校验时无论成功失败都立刻删除该 `captcha_id`,
防止「一个码刷一万个用户名」的撞库变体。
3. **光栅图,不是 SVG**。SVG 是文本,答案会以明文出现在页面源码或 DOM 里,
必须用位图。这里手写 PNG 编码器(zlib 是标准库),点阵字模自带。
4. **字符集避开易混字符**(0/O、1/I/L),降低正常用户输错概率。
字模
----
5×7 点阵,`#` 为前景。渲染时按整数倍放大并逐字符抖动,
再叠噪点与干扰线,普通 OCR 与「按色块切分」都会被破坏。
"""
import hmac
import os
import random
import secrets
import struct
import zlib
from datetime import datetime, timedelta
ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ" # 去掉 0 O 1 I L
_FONT = {
"2": (".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"),
"3": ("####.", "....#", "....#", ".###.", "....#", "....#", "####."),
"4": ("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."),
"5": ("#####", "#....", "####.", "....#", "....#", "#...#", ".###."),
"6": ("..##.", ".#...", "#....", "####.", "#...#", "#...#", ".###."),
"7": ("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."),
"8": (".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."),
"9": (".###.", "#...#", "#...#", ".####", "....#", "...#.", ".##.."),
"A": ("..#..", ".#.#.", "#...#", "#...#", "#####", "#...#", "#...#"),
"B": ("####.", "#...#", "#...#", "####.", "#...#", "#...#", "####."),
"C": (".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."),
"D": ("###..", "#..#.", "#...#", "#...#", "#...#", "#..#.", "###.."),
"E": ("#####", "#....", "#....", "####.", "#....", "#....", "#####"),
"F": ("#####", "#....", "#....", "####.", "#....", "#....", "#...."),
"G": (".###.", "#...#", "#....", "#.###", "#...#", "#...#", ".###."),
"H": ("#...#", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"),
"J": ("..###", "...#.", "...#.", "...#.", "...#.", "#..#.", ".##.."),
"K": ("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"),
"M": ("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"),
"N": ("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"),
"P": ("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."),
"Q": (".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"),
"R": ("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"),
"S": (".####", "#....", "#....", ".###.", "....#", "....#", "####."),
"T": ("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."),
"U": ("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."),
"V": ("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."),
"W": ("#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"),
"X": ("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"),
"Y": ("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."),
"Z": ("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"),
}
GLYPH_W, GLYPH_H = 5, 7
# 一次性验证码有效期(秒)。太短用户来不及看,太长给暴力破解留窗口。
TTL_SECONDS = 300
# 保留已过期记录多久后清理(仅用于体积控制,不影响安全性)
PURGE_AFTER_SECONDS = 3600
def random_code(length=4):
return "".join(secrets.choice(ALPHABET) for _ in range(length))
# ---------------- PNG 编码(手写,无依赖) ----------------
def _chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))
def encode_png(width, height, rgb):
"""把 RGB 字节串编码成 PNG(8 位真彩,无 alpha)。
rgb 长度必须是 width*height*3。每行前面加一个 filter 字节 0(None),
这是 PNG 对「一行一张扫描线」的强制要求。
"""
stride = width * 3
raw = bytearray()
for y in range(height):
raw.append(0)
raw += rgb[y * stride:(y + 1) * stride]
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
return (b"\x89PNG\r\n\x1a\n"
+ _chunk(b"IHDR", ihdr)
+ _chunk(b"IDAT", zlib.compress(bytes(raw), 9))
+ _chunk(b"IEND", b""))
class _Canvas:
"""极小的 RGB 画布。坐标越界自动丢弃,省得每处调用都判边界。"""
def __init__(self, w, h, bg):
self.w, self.h = w, h
self.buf = bytearray(bg * (w * h))
def dot(self, x, y, color):
if 0 <= x < self.w and 0 <= y < self.h:
i = (y * self.w + x) * 3
self.buf[i:i + 3] = bytes(color)
def rect(self, x, y, w, h, color):
for dy in range(h):
for dx in range(w):
self.dot(x + dx, y + dy, color)
def line(self, x0, y0, x1, y1, color):
"""Bresenham 直线。"""
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx + dy
while True:
self.dot(x0, y0, color)
if x0 == x1 and y0 == y1:
return
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def bytes(self):
return bytes(self.buf)
def render(code, width=150, height=56, scale=5, rng=None):
"""把验证码渲染成 PNG 字节串。
刻意不做「清晰排版」而是加抖动/噪点/干扰线:这是防机器识别的核心,
可读性靠放大字符(scale=5 即 25×35 像素)来补偿。
"""
rng = rng or random.SystemRandom()
n = len(code)
gap = 7
text_w = n * GLYPH_W * scale + (n - 1) * gap
if text_w + 16 > width:
width = text_w + 16
x0 = max(4, (width - text_w) // 2)
y0 = max(3, (height - GLYPH_H * scale) // 2)
# 背景取浅色,前景取深色 —— 深色底+浅字在缩略图上更容易糊,
# 而且打印/截图后对比度更差。
bg = tuple(rng.randint(238, 252) for _ in range(3))
cv = _Canvas(width, height, bg)
# 1) 干扰线(先画,压在字下面,不遮挡主体)
for _ in range(4):
cv.line(rng.randint(0, width - 1), rng.randint(0, height - 1),
rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(150, 205) for _ in range(3)))
# 2) 字符本体:逐字符随机取色 + 整数抖动,破坏固定网格切分
for i, ch in enumerate(code):
glyph = _FONT.get(ch)
if glyph is None:
continue
color = tuple(rng.randint(20, 105) for _ in range(3))
gx = x0 + i * (GLYPH_W * scale + gap) + rng.randint(-1, 1)
gy = y0 + rng.randint(-2, 2)
for row, bits in enumerate(glyph):
for col, bit in enumerate(bits):
if bit == "#":
cv.rect(gx + col * scale, gy + row * scale, scale, scale, color)
# 3) 前景噪点:少量深色点会让「按连通域找字符」变得不可靠
for _ in range(46):
cv.dot(rng.randint(0, width - 1), rng.randint(0, height - 1),
tuple(rng.randint(90, 190) for _ in range(3)))
# 4) 压在字上的细斜线:这是最有效的反 OCR 手段,但别太密,否则人也认不出
for _ in range(3):
y = rng.randint(2, height - 3)
cv.line(0, y, width - 1, y + rng.randint(-9, 9),
tuple(rng.randint(120, 175) for _ in range(3)))
return encode_png(width, height, cv.bytes())
# ---------------- 挑战的存储与校验(SQLite) ----------------
def _fmt(dt):
return dt.strftime("%Y-%m-%d %H:%M:%S")
def purge(conn, now=None):
"""清掉过期的挑战。每次新建时顺手调用(有 expires_at 索引,代价很小)。"""
now = now or datetime.now()
cut = _fmt(now - timedelta(seconds=PURGE_AFTER_SECONDS))
conn.execute("DELETE FROM captchas WHERE expires_at < ?", (cut,))
def create(conn, purpose, length=4, ttl=TTL_SECONDS, now=None):
"""新建一个挑战,返回 (captcha_id, code)。code 只应交给渲染函数,不要下发。"""
now = now or datetime.now()
code = random_code(length)
cid = secrets.token_urlsafe(24)
purge(conn, now)
conn.execute(
"INSERT INTO captchas(id,answer,purpose,created_at,expires_at) VALUES(?,?,?,?,?)",
(cid, code, purpose, _fmt(now), _fmt(now + timedelta(seconds=ttl))))
return cid, code
def verify(conn, captcha_id, answer, purpose, now=None):
"""校验并**立即作废**该挑战。返回 True/False。
永远不区分「过期」「不存在」「答案错」——对外只回一句人话,
避免把「这个 id 存在但答错了」这类信息透露给攻击者。
"""
if not captcha_id or answer is None:
return False
row = conn.execute("SELECT * FROM captchas WHERE id=?", (captcha_id,)).fetchone()
# 先删后判:无论结果如何都不允许第二次使用同一个 id
conn.execute("DELETE FROM captchas WHERE id=?", (captcha_id,))
if row is None or row["purpose"] != purpose:
return False
now = now or datetime.now()
if row["expires_at"] < _fmt(now):
return False
return hmac.compare_digest(str(row["answer"]), str(answer).strip().upper())
+127 -72
查看文件
@@ -5,10 +5,19 @@
"""采集主流程:云端增量 -> SQLite(去重、断点、漂移校验、运行记录)。
与原 fetch_usage.py 的差别:
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(request_id)` 承担
* 断点由 `SELECT MAX(ts)` 承担,不再需要全量读入内存
* 存档正本从 CSV 换成 SQLite,去重由 `ON CONFLICT(user_id, request_id)` 承担
* 断点由 `SELECT MAX(ts) WHERE user_id=?` 承担,不再需要全量读入内存
* 每次运行落一条 collect_runs 记录,页面据此展示任务历史与日志
* 采集互斥用文件锁,保证「单写者」——SQLite 只允许一个写进程
**多用户约定(最重要)**
所有写入函数都要求显式传入 `uid`,且 `uid` 是 `conn` 之后的第一个位置参数、
没有默认值。采集**只使用该账号自己保存的 Cookie**:
* 明文的 Cookie 从来只存在于内存里(库里是 crypto.encrypt 后的密文)
* 不再支持 `WB_COOKIE` 环境变量作为采集凭证 —— 那会让所有人共用一份凭证,
一旦生效就是「A 的采集把数据写进 B 的账号」这种串号事故。
环境变量只保留给 `manage.py import-creds` 做一次性导入。
"""
import csv
import os
@@ -27,8 +36,19 @@ class Busy(Exception):
"""已有采集在跑。"""
class NotReady(Exception):
"""该账号还没配好凭证 —— 不是错误,只是「没什么可做的」。"""
# ---------------- 互斥锁 ----------------
class _Lock:
"""全局单写者锁。
刻意**不做成按用户加锁**:SQLite 同一时刻只允许一个写事务,
按用户并行反而会在 busy_timeout 上互相拖死。串行跑完所有人的采集,
总耗时与并发差别很小(每个人一天也就拉一次)。
"""
def __init__(self, path=LOCK_PATH):
self.path = path
self.fd = None
@@ -65,18 +85,17 @@ class _Lock:
# ---------------- 运行记录 ----------------
def start_run(conn, trigger):
cur = conn.execute("INSERT INTO collect_runs(trigger,status,started_at) VALUES(?,?,?)",
(trigger, "running", db.now_str()))
def start_run(conn, uid, trigger):
cur = conn.execute("INSERT INTO collect_runs(user_id,trigger,status,started_at)"
" VALUES(?,?,?,?)", (uid, trigger, "running", db.now_str()))
return cur.lastrowid
def finish_run(conn, run_id, status, **kw):
fields = ["finished_at", "duration_ms", "win_from", "win_to", "fetched",
"added", "dup", "total", "conflicts", "exit_code", "message", "detail"]
sets = ["status=?", "finished_at=?"]
vals = [status, db.now_str()]
for f in fields[1:]:
for f in ("duration_ms", "win_from", "win_to", "fetched", "added", "dup",
"total", "conflicts", "exit_code", "message", "detail"):
if f in kw:
sets.append("%s=?" % f)
vals.append(kw[f])
@@ -86,10 +105,11 @@ def finish_run(conn, run_id, status, **kw):
# ---------------- 入库 ----------------
_UPSERT = """
INSERT INTO usage_records(request_id,ts,day,hour,model,client,credits,prompt,
INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,credits,prompt,
first_seen,last_seen,cloud_ts)
VALUES(:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,:first_seen,:last_seen,:cloud_ts)
ON CONFLICT(request_id) DO UPDATE SET
VALUES(:user_id,:request_id,:ts,:day,:hour,:model,:client,:credits,:prompt,
:first_seen,:last_seen,:cloud_ts)
ON CONFLICT(user_id,request_id) DO UPDATE SET
last_seen = excluded.last_seen,
cloud_ts = excluded.cloud_ts,
ts = CASE WHEN excluded.ts <> '' AND excluded.ts < usage_records.ts
@@ -107,10 +127,11 @@ ON CONFLICT(request_id) DO UPDATE SET
"""
def _row_dict(n):
def _row_dict(n, uid):
ts = (n.get("ts") or "").strip()[:19]
hh = ts[11:13]
return {
"user_id": uid,
"request_id": n["request_id"],
"ts": ts,
"day": ts[:10],
@@ -125,8 +146,8 @@ def _row_dict(n):
}
def upsert(conn, normalized, drift_tolerance=5, log=None):
"""按 request_id 去重写入。返回 (added, dup, conflicts) 与逐行警告。
def upsert(conn, uid, normalized, drift_tolerance=5, log=None):
"""按 (user_id, request_id) 去重写入。返回 (added, dup, conflicts) 与逐行警告。
每 200 条一个事务(连接是 autocommit,不显式 BEGIN 的话每条 INSERT 都要
单独 fsync)。upsert 本身幂等,所以按块提交是安全的。
@@ -135,7 +156,7 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
for n in normalized:
if not n.get("request_id") or not n.get("ts"):
continue
recs.append(_row_dict(n))
recs.append(_row_dict(n, uid))
added = dup = 0
conflicts = []
for i in range(0, len(recs), 200):
@@ -146,8 +167,9 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
if own_tx:
conn.execute("BEGIN")
try:
old = {x["request_id"]: x["ts"] for x in
conn.execute("SELECT request_id,ts FROM usage_records WHERE request_id IN (%s)" % ph, ids)}
old = {x["request_id"]: x["ts"] for x in conn.execute(
"SELECT request_id,ts FROM usage_records WHERE user_id=? AND request_id IN (%s)"
% ph, [uid] + ids)}
for r in chunk:
prev = old.get(r["request_id"])
if prev is None:
@@ -178,18 +200,33 @@ def upsert(conn, normalized, drift_tolerance=5, log=None):
return added, dup, conflicts
def record_count(conn):
return conn.execute("SELECT COUNT(*) FROM usage_records").fetchone()[0]
def record_count(conn, uid):
return conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0]
def last_ts(conn):
return conn.execute("SELECT MAX(ts) FROM usage_records").fetchone()[0]
def last_ts(conn, uid):
return conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0]
# ---------------- 凭证读取(解密) ----------------
def load_credentials(conn, uid):
"""取该账号的 (cookie, user_agent)。
Cookie 从库里读出来是密文,由 db.get_secret 解密;解不开会抛
db.SecretUnreadable(多半是 instance.json 里的 cookie_key 被换过),
这时应当明确告诉用户「重新粘贴 Cookie」,而不是当成「未配置」静默跳过。
"""
cookie = db.get_secret(conn, "cookie", uid).strip()
ua = (db.get_setting(conn, "user_agent", "", uid) or "").strip()
return cookie, ua
# ---------------- 主同步 ----------------
def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
def sync(conn, uid, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
do_write=True, log=None):
"""增量同步。
"""增量同步(仅限 uid 这个账号)。
trigger: manual | schedule | cli | startup(写进 collect_runs 便于区分来源)
返回 result dict;异常时抛出 ApiError(调用方决定如何展示)。
@@ -201,40 +238,39 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
if log:
log(msg)
s = db.get_settings(conn)
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
ua = (s.get("user_agent") or "").strip() or os.environ.get("WB_UA", "").strip()
s = db.get_settings(conn, uid=uid)
cookie, ua = load_credentials(conn, uid)
api_base = s.get("api_base") or config.API_BASE
api_path = s.get("api_path") or config.API_PATH
# 一律走 db.get_int/get_float:settings 表的值由后台页面自由输入,
# 直接 int() 会让一个手滑的字符把整条采集链路打断(历史 bug)。
page_size = db.get_int(conn, "page_size", 200)
rewind = db.get_int(conn, "rewind_minutes", 2)
drift = db.get_int(conn, "drift_tolerance_minutes", 5)
max_prompt = db.get_int(conn, "max_prompt", 0)
timeout = db.get_int(conn, "timeout", 30)
ssl_verify = db.get_bool(conn, "ssl_verify", True)
page_size = db.get_int(conn, "page_size", 200, uid)
rewind = db.get_int(conn, "rewind_minutes", 2, uid)
drift = db.get_int(conn, "drift_tolerance_minutes", 5, uid)
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
timeout = db.get_int(conn, "timeout", 30, uid)
ssl_verify = db.get_bool(conn, "ssl_verify", True, uid)
if verify_days is None:
verify_days = db.get_int(conn, "verify_days", 0)
verify_days = db.get_int(conn, "verify_days", 0, uid)
# 夹到合法区间,避免历史脏数据(如超大的 page_size)把云端打爆
lo, hi, _ = config.NUM_SETTINGS["page_size"]
page_size = max(lo, min(hi, page_size))
run_id = start_run(conn, trigger) if do_write else None
run_id = start_run(conn, uid, trigger) if do_write else None
t0 = time.time()
base = {"win_from": None, "win_to": None, "fetched": 0,
"added": 0, "dup": 0, "conflicts": 0}
if not cookie:
msg = "未配置 Cookie,请到「配置管理」页粘贴,或设置环境变量 WB_COOKIE"
msg = "未配置 Cookie,请到「配置管理」页粘贴自己账号的 Cookie"
_log("[error] " + msg)
if run_id:
finish_run(conn, run_id, "error", exit_code=2, message=msg,
duration_ms=int((time.time() - t0) * 1000), detail="\n".join(lines), **base)
raise ApiError(msg)
raise NotReady(msg)
total_before = record_count(conn)
tail = last_ts(conn)
total_before = record_count(conn, uid)
tail = last_ts(conn, uid)
now = datetime.now()
_log("存档:%d 条%s" % (total_before, (",最后记录 " + tail) if tail else "(空)"))
@@ -270,15 +306,17 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
new_rows = [client.normalize(r, max_prompt=max_prompt) for r in raw]
_log("云端返回:%d 条" % len(raw))
added, dup, conflicts = upsert(conn, new_rows, drift_tolerance=drift, log=_log)
added, dup, conflicts = upsert(conn, uid, new_rows, drift_tolerance=drift, log=_log)
# 整日完整性校验(默认关闭;用于排查缺记录)
if verify_days > 0:
days = [r["day"] for r in conn.execute(
"SELECT DISTINCT day FROM usage_records ORDER BY day DESC LIMIT ?", (verify_days,))]
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day DESC LIMIT ?",
(uid, verify_days))]
_log("完整性校验:最近 %d 天" % len(days))
for d in sorted(days):
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE day=?", (d,)).fetchone()[0]
local = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=? AND day=?",
(uid, d)).fetchone()[0]
d0 = datetime.strptime(d, "%Y-%m-%d")
try:
raw2, t2 = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
@@ -290,14 +328,15 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
continue
cloud = t2.get(d, 0)
if local < cloud:
a2, _, _ = upsert(conn, [client.normalize(r, max_prompt=max_prompt) for r in raw2],
a2, _, _ = upsert(conn, uid,
[client.normalize(r, max_prompt=max_prompt) for r in raw2],
drift_tolerance=drift)
added += a2
_log(" %s:云端 %d / 本地 %d → 补入 %d 条" % (d, cloud, local, a2))
else:
_log(" %s:云端 %d / 本地 %d OK" % (d, cloud, local))
total_after = record_count(conn)
total_after = record_count(conn, uid)
status = "warn" if conflicts else "ok"
msg = "新增 %d 条,重复 %d 条,存档共 %d 条" % (added, dup, total_after)
_log("RESULT: added=%d dup=%d total=%d" % (added, dup, total_after))
@@ -313,27 +352,31 @@ def sync(conn, trigger="manual", from_dt=None, to_dt=None, verify_days=None,
"total": total_after, "conflicts": len(conflicts),
"win_from": start.strftime("%Y-%m-%d %H:%M:%S"),
"win_to": end.strftime("%Y-%m-%d %H:%M:%S"),
"message": msg, "lines": lines, "run_id": run_id}
"message": msg, "lines": lines, "run_id": run_id, "uid": uid}
def run_sync(trigger="manual", **kw):
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。"""
"""带锁的同步入口(供 CLI / 调度器 / 页面手动触发共用)。
必须显式给出 `uid=...`;漏传会由 sync() 直接报 TypeError,
不会退化成「用某个默认账号去采集」。
"""
with _Lock():
conn = db.thread_conn()
return sync(conn, trigger=trigger, **kw)
# ---------------- 补全 / 导入 / 导出 ----------------
def fill_prompt(conn, log=print):
"""补全缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
s = db.get_settings(conn)
cookie = (s.get("cookie") or "").strip() or os.environ.get("WB_COOKIE", "").strip()
ua = (s.get("user_agent") or "").strip()
max_prompt = db.get_int(conn, "max_prompt", 0)
def fill_prompt(conn, uid, log=print):
"""补全该账号缺失的 User Prompt(官网导出的 xlsx 会丢约 22%,云端仍保留)。"""
s = db.get_settings(conn, uid=uid)
cookie, ua = load_credentials(conn, uid)
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
if not cookie:
raise ApiError("未配置 Cookie")
raise NotReady("未配置 Cookie")
todo = conn.execute("SELECT request_id, day FROM usage_records "
"WHERE COALESCE(prompt,'')='' ORDER BY day").fetchall()
"WHERE user_id=? AND COALESCE(prompt,'')='' ORDER BY day",
(uid,)).fetchall()
if not todo:
log("没有缺失的 User Prompt")
return 0
@@ -345,9 +388,9 @@ def fill_prompt(conn, log=print):
raw, _ = client.fetch_range(d0, d0.replace(hour=23, minute=59, second=59),
cookie, ua, s.get("api_base") or config.API_BASE,
s.get("api_path") or config.API_PATH,
page_size=db.get_int(conn, "page_size", 200),
timeout=db.get_int(conn, "timeout", 30),
ssl_verify=db.get_bool(conn, "ssl_verify", True))
page_size=db.get_int(conn, "page_size", 200, uid),
timeout=db.get_int(conn, "timeout", 30, uid),
ssl_verify=db.get_bool(conn, "ssl_verify", True, uid))
for r in raw:
rid = (r.get("requestId") or "").strip()
if rid:
@@ -357,21 +400,22 @@ def fill_prompt(conn, log=print):
for row in todo:
p = pool.get(row["request_id"], "")
if p:
conn.execute("UPDATE usage_records SET prompt=? WHERE request_id=?", (p, row["request_id"]))
conn.execute("UPDATE usage_records SET prompt=? WHERE user_id=? AND request_id=?",
(p, uid, row["request_id"]))
n += 1
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE COALESCE(prompt,'')=''").fetchone()[0]
left = conn.execute("SELECT COUNT(*) FROM usage_records WHERE user_id=?"
" AND COALESCE(prompt,'')=''", (uid,)).fetchone()[0]
log("补全 %d 条,仍为空 %d 条" % (n, left))
return n
def import_xlsx(conn, path, log=print):
def import_xlsx(conn, uid, path, log=print):
"""从官网「用量明细 - 导出」的 xlsx 合入(按 request_id 去重)。"""
try:
import openpyxl
except ImportError:
raise ApiError("需要 openpyxl:pip install openpyxl")
s = db.get_settings(conn)
max_prompt = db.get_int(conn, "max_prompt", 0)
max_prompt = db.get_int(conn, "max_prompt", 0, uid)
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
it = wb.worksheets[0].iter_rows(values_only=True)
header = [str(c).strip() if c is not None else "" for c in next(it)]
@@ -401,17 +445,24 @@ def import_xlsx(conn, path, log=print):
rows.append({"request_id": rid, "ts": t, "credits": round(cr, 2), "prompt": px,
"model": str(row[i_m] or "-").strip() or "-",
"client": str(row[i_cl] or "-").strip() or "-"})
added, dup, _ = upsert(conn, rows)
log("[xlsx] 读取 %d 条,去重后新增 %d 条,存档共 %d 条" % (len(rows), added, record_count(conn)))
added, dup, _ = upsert(conn, uid, rows)
log("[xlsx] 读取 %d 条,去重后新增 %d 条,该账号存档共 %d 条"
% (len(rows), added, record_count(conn, uid)))
return added
def export_csv(conn, path=None):
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。"""
path = path or os.path.join(config.EXPORT_DIR, "usage_records.csv")
def export_csv(conn, uid, path=None, username=None):
"""导出与旧存档 / 官网 xlsx 完全同构的 CSV(备份与对端交换用)。
文件名带账号名:多用户下所有人导出到同一个目录,
不带归属就会互相覆盖。
"""
if path is None:
tag = username or ("u%s" % uid)
path = os.path.join(config.EXPORT_DIR, "usage_records_%s.csv" % tag)
os.makedirs(os.path.dirname(path), exist_ok=True)
rows = conn.execute("SELECT request_id,credits,prompt,model,client,ts FROM usage_records "
"ORDER BY ts, request_id")
"WHERE user_id=? ORDER BY ts, request_id", (uid,))
n = 0
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.writer(f)
@@ -423,8 +474,12 @@ def export_csv(conn, path=None):
return path, n
def migrate_from_csv(conn, path, log=print):
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。"""
def migrate_from_csv(conn, uid, path, log=print):
"""把旧版 data/usage_records.csv 全量导入 SQLite(幂等,可重复执行)。
导入的数据归属 `uid` 指定的账号 —— 老存档是单用户的,
必须由调用方明确「这份数据算谁的」。
"""
if not os.path.exists(path):
raise FileNotFoundError(path)
with open(path, "rb") as fb:
@@ -445,8 +500,8 @@ def migrate_from_csv(conn, path, log=print):
"prompt": " ".join(str(r.get("User Prompt") or "").split()),
"model": (r.get("模型") or "-").strip() or "-",
"client": (r.get("客户端") or "-").strip() or "-"})
before = record_count(conn)
added, dup, _ = upsert(conn, recs)
before = record_count(conn, uid)
added, dup, _ = upsert(conn, uid, recs)
log("[migrate] 源文件 %d 条 → 新增 %d / 已存在 %d,入库前 %d 条,现共 %d 条"
% (len(recs), added, dup, before, record_count(conn)))
% (len(recs), added, dup, before, record_count(conn, uid)))
return added
+137 -24
查看文件
@@ -28,8 +28,11 @@ EXPORT_DIR = os.path.join(DATA_DIR, "exports")
APP_LOG = os.path.join(LOG_DIR, "app.log")
INSTANCE_FILE = os.path.join(DATA_DIR, "instance.json")
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)
# 旧版脚本项目的存档(迁移用;--migrate-csv 默认读这里)。
# v1.3.0 起旧版被收进工作区级的 legacy-v1/ 目录,所以第一个候选是新位置,
# 后面两个保留以兼容「还没挪走」的部署。
LEGACY_CSV_CANDIDATES = [
os.path.join(os.path.dirname(BASE_DIR), "legacy-v1", "data", "usage_records.csv"),
os.path.join(os.path.dirname(BASE_DIR), "data", "usage_records.csv"),
os.path.join(BASE_DIR, "data", "usage_records.csv"),
]
@@ -39,9 +42,23 @@ API_BASE = "https://www.workbuddy.cn"
API_PATH = "/billing/meter/get-user-request-usage"
# ---------------- 采集参数默认值(可被 settings 表覆盖)----------------
# settings 表是 (user_id, key) 复合主键:user_id=0 表示**实例级**,
# 其余表示**个人级**(每个账号一份,互不可见)。见下方的 GLOBAL_KEYS。
DEFAULTS = {
# ---- 实例级:连接的是哪个云端 ----
"api_base": API_BASE,
"api_path": API_PATH,
# ---- 实例级:开放注册与防攻击策略 ----
"allow_register": "1", # 是否开放自助注册
"register_max_per_ip": "3", # 同一 IP 每天最多注册几个账号
"captcha_policy": "always", # always | adaptive | off(见 CAPTCHA_POLICIES)
"captcha_length": "4", # 验证码字符数 4~6
# ---- 实例级:采集调度(全实例统一,见 GLOBAL_KEYS)----
"schedule_enabled": "1",
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
# ---- 实例级:采集参数 ----
"page_size": "200",
"rewind_minutes": "2", # 断点回退分钟数
"drift_tolerance_minutes": "5", # 云端比本地早超过该值才告警
@@ -49,19 +66,66 @@ DEFAULTS = {
"verify_days": "0", # 每次采集后做整日完整性校验的天数
"timeout": "30",
"ssl_verify": "1", # 校验云端 HTTPS 证书(cookie 是凭证,不该裸奔)
# 调度
"schedule_enabled": "1",
"schedule_times": "09:00,17:00", # 每天固定时刻(逗号分隔,本地时区)
"catch_up": "1", # 启动时补跑当天已错过且未执行的槽位
"catch_up_grace_hours": "12", # 超过该小时数就不再补跑
# 凭证
# ---- 个人级:凭证(每个账号自己的,Cookie 静态加密后入库)----
"cookie": "",
"user_agent": ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36"),
}
# ---------------- 配置的作用域与写权限(改这里之前先读完整段)----------------
#
# 三级回落:**个人级(user_id=n) -> 实例级(user_id=0) -> config.DEFAULTS**。
# 但「谁能写哪个键」和「键存在哪一级」是两件事,本项目把两者对齐成一条规则:
#
# 普通用户只能写 USER_EDITABLE_KEYS(本人凭证);
# 其余所有键都归管理员,且一律存在**实例级**(user_id=0)。
#
# 为什么采集参数 / 调度也要放到实例级,而不是「个人级但只有管理员能写」:
# 如果它们只在管理员自己的 user_id 下,其它账号读取时会回落到
# DEFAULTS,管理员改的值对别人**完全不生效** —— 那才是真正的坑。
# 统一放实例级,语义是「一台部署一套采集与调度策略」,读起来也简单。
GLOBAL_KEYS = {
# 云端接口
"api_base", "api_path",
# 开放注册与防攻击策略
"allow_register", "register_max_per_ip", "captcha_policy", "captcha_length",
# 采集调度(v1.3.0 起为实例级:普通用户只读,不能设置频率)
"schedule_enabled", "schedule_times", "catch_up", "catch_up_grace_hours",
# 采集参数(同理:允许普通用户调 page_size/关 ssl_verify 都是越权)
"page_size", "rewind_minutes", "drift_tolerance_minutes", "max_prompt",
"verify_days", "timeout", "ssl_verify",
}
# 普通用户**唯一**可写的两个键:本人账号的云端凭证。
# cookie —— 静态加密后入库,页面/接口只回掩码
# user_agent —— 必须与拿 Cookie 的那次请求同源,所以和 Cookie 归在一起
# 这两个键是个人级、且不参与实例级回落(见 db.NO_FALLBACK_KEYS):
# 回落等于「用别人的身份采集」,是最严重的一类越权。
USER_EDITABLE_KEYS = {"cookie", "user_agent"}
def is_user_key(key):
"""是否属于「个人凭证」类配置。"""
return key in USER_EDITABLE_KEYS
def writable_by(key, is_admin):
"""当前角色能否写这个键 —— 前后端与测试都走这一个判断,避免两处规则漂移。"""
if key in USER_EDITABLE_KEYS:
return True
return bool(is_admin)
# 验证码策略
CAPTCHA_POLICIES = {
"always": "始终要求(默认,最安全)",
"adaptive": "仅在同一来源连续失败 2 次后要求",
"off": "关闭(仅当前面有可信网关做鉴权时才考虑)",
}
# 页面展示用:哪些键属于「敏感」,在界面上做掩码
SECRET_KEYS = {"cookie"}
# 需要静态加密后再入库的键(明文只存在于内存与请求体里)
ENCRYPTED_KEYS = {"cookie"}
# 内部簿记键前缀:调度槽位标记等,**不属于用户可配置项**,
# 不在 /api/settings 里回传,也不允许通过接口写入。
@@ -72,6 +136,11 @@ def is_internal_key(key):
return any(str(key).startswith(p) for p in INTERNAL_PREFIXES)
def is_global_key(key):
"""实例级键:所有账号共用一份,只有管理员可写。"""
return key in GLOBAL_KEYS
# ---------------- 设置项校验表 ----------------
# 这些键必须能安全地转成数字:后台页面是自由文本框,用户敲错一个字符
# 就会让采集在 int() 处抛 ValueError(历史 bug),所以写入时校验、读取时兜底。
@@ -84,8 +153,10 @@ NUM_SETTINGS = {
"verify_days": (0, 90, "天"),
"timeout": (5, 300, "秒"),
"catch_up_grace_hours": (1, 168, "小时"),
"captcha_length": (4, 6, "个字符"),
"register_max_per_ip": (1, 50, "个/天"),
}
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
BOOL_SETTINGS = {"schedule_enabled", "catch_up", "allow_register"}
_TRUE = ("1", "true", "yes", "on", "是", "启用")
@@ -127,6 +198,12 @@ def normalize_setting(key, raw):
return None, "每日时刻格式不对,正确写法如 09:00,17:00"
return ",".join(parsed), None
if key == "captcha_policy":
v = str(raw).strip().lower()
if v not in CAPTCHA_POLICIES:
return None, "验证码策略只能是 %s" % " / ".join(sorted(CAPTCHA_POLICIES))
return v, None
if key in ("api_base", "api_path"):
v = str(raw).strip()
if not v:
@@ -136,6 +213,7 @@ def normalize_setting(key, raw):
return v, None
if key == "cookie":
# 明文原样返回,由 db.set_setting 负责加密后再落库
return str(raw).strip(), None
return str(raw).strip(), None
@@ -144,29 +222,64 @@ def normalize_setting(key, raw):
DEFAULT_HOST = "0.0.0.0" # 局域网可访问
DEFAULT_PORT = 8848
SESSION_HOURS = 12
MAX_LOGIN_FAILS = 5 # 同 IP 连续失败次数
MAX_LOGIN_FAILS = 5 # 同 IP / 同用户名连续失败次数
LOGIN_LOCK_MINUTES = 10
# ---------------- 账号与口令策略 ----------------
USERNAME_RE = r"^[A-Za-z0-9][A-Za-z0-9_.\-]{2,31}$" # 3~32 位,字母开头
PASSWORD_MIN = 8
PASSWORD_MAX = 128
# 开启注册后,未配置 Cookie 的新账号在概览页会被提示「去配置」——
# 采集只使用**本人**的 Cookie,绝不复用别人的(否则会串号)。
PROFILE_EMAIL_MAX = 128
# 会话 Cookie 是否只走 HTTPS。纯局域网 HTTP 部署必须留 0,否则浏览器不发送,
# 表现为「登录成功但立刻又跳回登录页」,极难排查。
COOKIE_SECURE = os.environ.get("WB_COOKIE_SECURE", "0").strip() in ("1", "true", "yes", "on")
def ensure_dirs():
for d in (DATA_DIR, LOG_DIR, EXPORT_DIR):
os.makedirs(d, exist_ok=True)
def _instance_read():
"""读 data/instance.json(不存在或损坏都当空字典,不让启动因此失败)。"""
ensure_dirs()
if not os.path.exists(INSTANCE_FILE):
return {}
try:
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
return json.load(f) or {}
except (OSError, ValueError):
return {}
def _instance_init(key, maker):
"""取 instance.json 里的 key,没有就生成并持久化。"""
data = _instance_read()
val = data.get(key)
if not val:
val = maker()
data[key] = val
try:
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except OSError:
pass # 只读文件系统时退化为「本次进程内有效」
return val
def secret_key():
"""SECRET_KEY 持久化在 data/instance.json,避免每次重启把登录态全踢掉。"""
ensure_dirs()
data = {}
if os.path.exists(INSTANCE_FILE):
try:
with open(INSTANCE_FILE, "r", encoding="utf-8") as f:
data = json.load(f) or {}
except (OSError, ValueError):
data = {}
key = data.get("secret_key")
if not key:
key = secrets.token_hex(32)
data["secret_key"] = key
with open(INSTANCE_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return key
return _instance_init("secret_key", lambda: secrets.token_hex(32))
def encryption_key():
"""Cookie 静态加密的主密钥(32 字节)。
与 SECRET_KEY **分开**存放:两者轮换的代价完全不同 —— 换 SECRET_KEY
只是让所有人重新登录,换这把会让已存的 Cookie 全部解不开。
所以混用同一个值会让「想轮换其中一个」变成一件危险的事。
"""
return bytes.fromhex(_instance_init("cookie_key", lambda: secrets.token_hex(32)))
+177
查看文件
@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""对称加密 —— 给「数据库里的 Cookie」做静态加密。
为什么要自己写而不用 `cryptography` / `pycryptodome`
----------------------------------------------------
本项目刻意保持零第三方依赖(`requirements.txt` 只有 Flask / waitress / openpyxl),
而这里需要的原语只有两个,都能在 RFC 里逐行对照实现:
* **ChaCha20** 流密码(RFC 8439 §2.3)—— 加密
* **HMAC-SHA256**(RFC 2104)—— 认证,采用 **encrypt-then-MAC**
明文密钥不是口令而是 32 字节随机数,所以派生不需要慢速 KDF
(PBKDF2/scrypt 是为「低熵口令」设计的),用 HMAC 做一次密钥分离即可。
密文格式
--------
v1.<b64(salt)>.<b64(nonce)>.<b64(ciphertext)>.<b64(tag)>
* salt —— 16 字节随机,用于把主密钥分离成 enc/mac 两把子密钥
* nonce —— 12 字节随机,每次加密都重新生成(绝不复用)
* tag —— HMAC(mac_key, nonce || ciphertext) 的 SHA-256
**不做压缩**:Cookie 是几百到几千字节的高熵串,压缩比接近 1,
反而会引入 CRIME 类侧信道,不值得。
向后兼容
--------
`decrypt()` 遇到不是 `v1.` 开头的值会**原样返回**,这样从旧版本
(Cookie 明文存在 settings 表)升级过来不会立刻炸;下次写入时自然
会被改写为密文(见 `db.set_secret`)。
"""
import base64
import hashlib
import hmac
import secrets
import struct
PREFIX = "v1."
# RFC 8439 §2.3 的常数:"expand 32-byte k"
_CONST = b"expand 32-byte k"
_MASK = 0xFFFFFFFF
# ---------------- ChaCha20 ----------------
def _rotl32(v, c):
return ((v << c) & _MASK) | (v >> (32 - c))
def _quarter_round(s, a, b, c, d):
"""RFC 8439 §2.1。就地修改 s。"""
s[a] = (s[a] + s[b]) & _MASK
s[d] = _rotl32(s[d] ^ s[a], 16)
s[c] = (s[c] + s[d]) & _MASK
s[b] = _rotl32(s[b] ^ s[c], 12)
s[a] = (s[a] + s[b]) & _MASK
s[d] = _rotl32(s[d] ^ s[a], 8)
s[c] = (s[c] + s[d]) & _MASK
s[b] = _rotl32(s[b] ^ s[c], 7)
def chacha20_block(key32, counter, nonce12):
"""产出一个 64 字节的块(RFC 8439 §2.3.2)。"""
st = (list(struct.unpack("<4I", _CONST))
+ list(struct.unpack("<8I", key32))
+ [counter & _MASK]
+ list(struct.unpack("<3I", nonce12)))
w = list(st)
for _ in range(10): # 10 组 = 20 轮
_quarter_round(w, 0, 4, 8, 12)
_quarter_round(w, 1, 5, 9, 13)
_quarter_round(w, 2, 6, 10, 14)
_quarter_round(w, 3, 7, 11, 15)
_quarter_round(w, 0, 5, 10, 15)
_quarter_round(w, 1, 6, 11, 12)
_quarter_round(w, 2, 7, 8, 13)
_quarter_round(w, 3, 4, 9, 14)
return struct.pack("<16I", *[(w[i] + st[i]) & _MASK for i in range(16)])
def _keystream(key32, nonce12, n):
"""按需生成 n 字节密钥流。counter 从 1 开始(0 号块留给 Poly1305 用,这里不用)。"""
out = bytearray()
counter = 1
while len(out) < n:
out += chacha20_block(key32, counter, nonce12)
counter += 1
return bytes(out[:n])
def _xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
# ---------------- 密钥分离 ----------------
def _derive(master, salt, label):
"""HMAC 做一次密钥分离:主密钥是高熵随机数,一次 HMAC 足够。"""
return hmac.new(master, salt + label, hashlib.sha256).digest()
def _b64(raw):
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text):
pad = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + pad)
# ---------------- 对外接口 ----------------
def is_encrypted(value):
return isinstance(value, str) and value.startswith(PREFIX)
def encrypt(plaintext, master):
"""加密任意字符串;空值原样返回(不产生「有密文的空值」这种歧义状态)。
master: 32 字节主密钥(bytes)。返回可直接存库的 ASCII 字符串。
"""
if plaintext is None or plaintext == "":
return ""
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
raise ValueError("主密钥必须是 32 字节")
data = plaintext.encode("utf-8") if isinstance(plaintext, str) else bytes(plaintext)
salt = secrets.token_bytes(16)
nonce = secrets.token_bytes(12)
enc_key = _derive(bytes(master), salt, b"enc")
mac_key = _derive(bytes(master), salt, b"mac")
ct = _xor(data, _keystream(enc_key, nonce, len(data)))
tag = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
return PREFIX + ".".join((_b64(salt), _b64(nonce), _b64(ct), _b64(tag)))
class DecryptError(ValueError):
"""密文被篡改、格式损坏或密钥不对。"""
def decrypt(token, master):
"""解密。
* 非密文(历史明文、空串)原样返回,便于平滑升级
* 密文校验失败抛 DecryptError —— **绝不**「失败就返回原值」,
否则一次篡改会被静默当成合法明文用下去
"""
if not token or not isinstance(token, str):
return ""
if not token.startswith(PREFIX):
return token # 兼容旧的明文存储
if not isinstance(master, (bytes, bytearray)) or len(master) != 32:
raise DecryptError("主密钥必须是 32 字节")
parts = token[len(PREFIX):].split(".")
if len(parts) != 4:
raise DecryptError("密文格式不正确")
try:
salt, nonce, ct, tag = (_unb64(p) for p in parts)
except (ValueError, TypeError) as e:
raise DecryptError("密文 base64 解码失败:%s" % e)
if len(salt) != 16 or len(nonce) != 12 or len(tag) != 32:
raise DecryptError("密文长度不合法")
mac_key = _derive(bytes(master), salt, b"mac")
want = hmac.new(mac_key, nonce + ct, hashlib.sha256).digest()
# 先比 MAC 再解密:认证失败时不接触密文,避免 padding/解析类侧信道
if not hmac.compare_digest(want, tag):
raise DecryptError("完整性校验失败(密文被篡改或主密钥已更换)")
enc_key = _derive(bytes(master), salt, b"enc")
return _xor(ct, _keystream(enc_key, nonce, len(ct))).decode("utf-8")
def fingerprint(plaintext):
"""值指纹:用于「是否换过」的判断,不能反推原文。"""
if not plaintext:
return ""
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()[:16]
+360 -37
查看文件
@@ -2,7 +2,7 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""SQLite 访问层。
"""SQLite 访问层(多用户版)。
并发约定(重要):
* WAL 模式 —— 采集写入期间页面查询不会被 `database is locked` 挡住
@@ -10,18 +10,46 @@
(由 scheduler / CLI 共享的 collect.lock 保证)
* busy_timeout=8s —— 偶发并发时等待而不是立刻报错
* 每个线程独立连接(sqlite3 默认禁止跨线程复用连接)
多用户约定(改代码前务必先读):
* `user_id = 0` 在 settings / collect_runs / audit_log 里表示**实例级**;
usage_records 里 0 是「历史遗留数据尚未归属」的兜底值,正常不会出现。
* **settings 里只有两个键是个人级:`cookie` 与 `user_agent`**(即
`config.USER_EDITABLE_KEYS`)。调度、采集参数、接口地址、注册策略全部
是实例级(`config.GLOBAL_KEYS`),`set_setting()` 会把它们强制写到
user_id=0 —— 因此「管理员改了但别人不生效」这类 bug 在结构上不存在。
* `get_settings()` 会把 `ENCRYPTED_KEYS`(Cookie)**一律置空**;
要拿明文只有 `get_secret()` 一条路。这样任何「顺手打印一下全部配置」
的代码都不可能把凭证带出去。
* `NO_FALLBACK_KEYS`(Cookie / User-Agent)**不参与实例级回退**:
Cookie 是账号凭证,回落等于串号,是最严重的一类越权。
* `slot:*` 调度簿记键是**个人级**(每个账号各自记「今天这个槽位跑过没」),
虽然时刻本身是实例级的 —— 这两个千万别一起改。
"""
import os
import sqlite3
import threading
from datetime import datetime
from . import config
from . import config, crypto
_local = threading.local()
_init_lock = threading.Lock()
_initialized = False
# 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。
# 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键)
# 2 -> 多用户布局(见 schema.sql 顶部说明)
# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS)
DB_SCHEMA_VERSION = 3
# 这些键即使个人作用域没有值,也**不**回落到实例级
NO_FALLBACK_KEYS = {"cookie", "user_agent"}
class SecretUnreadable(Exception):
"""密文解不开 —— 通常是 data/instance.json 里的 cookie_key 被换过。"""
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -58,87 +86,382 @@ def close_thread_conn():
_local.conn = None
# ---------------- 初始化 ----------------
def _schema_sql():
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
return f.read()
# ---------------- 初始化 / 迁移 ----------------
def _table_cols(conn, table):
return {r["name"] for r in conn.execute("PRAGMA table_info(%s)" % table)}
def _has_table(conn, name):
return bool(conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)).fetchone())
def _first_owner_uid(conn):
"""历史数据归谁:优先第一个管理员,其次第一个账号。"""
row = conn.execute("SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
if row:
return row["id"]
row = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return row["id"] if row else 0
def _drop_all_user_indexes(conn):
"""删掉本项目自建的全部索引(idx_*)。
必须先删:`ALTER TABLE ... RENAME TO` 会**把索引一起带走**(名字仍指向
改名后的表),于是后面 `CREATE INDEX IF NOT EXISTS` 会被当成「已存在」
静默跳过,最终新表上一个索引都没有 —— 表面完全正常,只是慢几百倍。
`sqlite_autoindex_*` 是主键/唯一约束的隐式索引,不能动,靠前缀过滤掉。
"""
names = [r["name"] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'")]
for n in names:
conn.execute('DROP INDEX IF EXISTS "%s"' % n)
return names
def _migrate(conn):
"""把老库升到 DB_SCHEMA_VERSION。幂等,返回迁移说明列表。
顺序不能变:
1. 删索引(否则改名会把索引名占住,新表建不出索引)
2. 改名主键变了的表(settings / usage_records)
3. ALTER 加列(collect_runs / audit_log / users)
4. 跑 schema.sql —— 此时所有列都齐了,表与索引一次建全
5. 回填数据、删掉 _v1_ 旧表
"""
ver = conn.execute("PRAGMA user_version").fetchone()[0]
if ver >= DB_SCHEMA_VERSION:
return []
done = []
ts = now_str()
owner = _first_owner_uid(conn)
tset = _table_cols(conn, "settings")
tur = _table_cols(conn, "usage_records")
rebuild_settings = "user_id" not in tset
rebuild_records = "user_id" not in tur
_drop_all_user_indexes(conn)
if rebuild_settings:
conn.execute("ALTER TABLE settings RENAME TO _v1_settings")
if rebuild_records:
conn.execute("ALTER TABLE usage_records RENAME TO _v1_usage_records")
# ---- 只加列的表用 ALTER,代价小得多。放在建表之前,好让 schema.sql
# 里的 CREATE INDEX 一次就成功(索引引用了这些新列)----
if "user_id" not in _table_cols(conn, "collect_runs"):
conn.execute("ALTER TABLE collect_runs ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
conn.execute("UPDATE collect_runs SET user_id=?", (owner,))
done.append("collect_runs 增加 user_id")
if "user_id" not in _table_cols(conn, "audit_log"):
conn.execute("ALTER TABLE audit_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0")
done.append("audit_log 增加 user_id")
tusers = _table_cols(conn, "users")
for col, ddl in (("email", "TEXT"),
("status", "TEXT NOT NULL DEFAULT 'active'"),
("register_ip", "TEXT"),
("last_login_ip", "TEXT")):
if col not in tusers:
conn.execute("ALTER TABLE users ADD COLUMN %s %s" % (col, ddl))
done.append("users 增加 %s" % col)
conn.execute("UPDATE users SET status='active' WHERE status IS NULL OR status=''")
conn.executescript(_schema_sql()) # 建出新表 + 全部索引
if rebuild_settings:
# 老布局里 cookie / user_agent 是实例级的 —— 留在实例级等于
# 「所有人共用管理员的凭证」,必须归到 owner 名下,且之后永不回落。
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT 0,key,value,updated_at FROM _v1_settings"
" WHERE key NOT IN ('cookie','user_agent')")
conn.execute("INSERT INTO settings(user_id,key,value,updated_at)"
" SELECT ?,key,value,updated_at FROM _v1_settings"
" WHERE key IN ('cookie','user_agent') AND value IS NOT NULL AND value <> ''",
(owner,))
conn.execute("DROP TABLE _v1_settings")
done.append("settings 改为 (user_id,key) 复合主键:旧值归实例级,"
"Cookie/UA 已归属账号 #%d" % owner)
if rebuild_records:
conn.execute("INSERT INTO usage_records(user_id,request_id,ts,day,hour,model,client,"
"credits,prompt,first_seen,last_seen,cloud_ts)"
" SELECT ?,request_id,ts,day,hour,model,client,credits,prompt,"
"first_seen,last_seen,cloud_ts FROM _v1_usage_records", (owner,))
conn.execute("DROP TABLE _v1_usage_records")
done.append("usage_records 增加 user_id,主键改为 (user_id, request_id),"
"历史数据归属账号 #%d" % owner)
conn.execute("UPDATE users SET is_admin=1 WHERE id=?", (owner,))
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
if done:
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "schema_migrate", ";".join(done)[:500], "127.0.0.1"))
return done
def _encrypt_legacy_secrets(conn):
"""把历史**明文**凭证就地加密。幂等,可反复执行。
老版本把 Cookie 直接明文写进 settings 表。升级后即便功能正常,
「库里躺着一段明文凭证」本身就是风险:备份文件、磁盘镜像、
误提交、排障时的一次 dump 都会把它带出去。
这里对 `config.ENCRYPTED_KEYS` 里所有**非 v1. 前缀**的值加密一次;
已加密的值会因前缀判定被跳过,所以每次启动跑一遍是安全的。
"""
if not config.ENCRYPTED_KEYS:
return []
key = config.encryption_key()
done = []
for r in conn.execute("SELECT user_id,key,value FROM settings").fetchall():
if r["key"] not in config.ENCRYPTED_KEYS:
continue
raw = r["value"]
if not raw or crypto.is_encrypted(raw):
continue
conn.execute("UPDATE settings SET value=?,updated_at=? WHERE user_id=? AND key=?",
(crypto.encrypt(raw, key), now_str(), r["user_id"], r["key"]))
done.append("settings[uid=%s].%s" % (r["user_id"], r["key"]))
return done
def _promote_personal_to_global(conn):
"""把「原本个人级、现在实例级」的配置收敛到 user_id=0。幂等,可反复执行。
v1.3.0 把调度与采集参数从个人级升为实例级(普通用户只读)。升级时
必须做两件事,否则会静默丢配置:
1. **把第一个管理员的个人值提升到实例级** —— 管理员此前设的
`schedule_times=08:00` 若不提升,读取路径会因为「全局键不再看个人
作用域」而直接跳过它,表现成「设置莫名其妙回到默认值」。
2. **删掉所有个人作用域里的全局键** —— 留着不会被读(get_settings 会
跳过),但会让后面排障的人以为「这个键是个人级的」。
对本来就属于实例级的键(api_base / allow_register 等)这是空操作。
"""
owner = _first_owner_uid(conn)
promoted, cleaned = [], 0
for k in sorted(config.GLOBAL_KEYS):
if owner:
mine = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(owner, k)).fetchone()
if mine is not None and mine["value"] is not None:
cur = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(k,)).fetchone()
if cur is None or cur["value"] != mine["value"]:
set_setting(conn, k, mine["value"], 0) # 全局键强制落 uid=0
promoted.append(k)
# DELETE 的 rowcount 对「没有匹配行」返回 0,所以这里天然幂等
cur2 = conn.execute("DELETE FROM settings WHERE user_id<>0 AND key=?", (k,))
cleaned += cur2.rowcount or 0
return promoted, cleaned
def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=None):
"""建表 + 灌默认配置。可重复执行(幂等)。"""
"""建表 / 迁移 / 灌默认配置。可重复执行(幂等)。返回迁移说明列表。"""
global _initialized
own = conn is None
conn = conn or connect()
try:
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "schema.sql"),
"r", encoding="utf-8") as f:
conn.executescript(f.read())
# 默认配置(不覆盖已有值)
if not _has_table(conn, "users"):
# 全新库:schema.sql 一次到位(避免走迁移路径去 ALTER 不存在的表)
conn.executescript(_schema_sql())
conn.execute("PRAGMA user_version=%d" % DB_SCHEMA_VERSION)
migrated = []
else:
migrated = _migrate(conn)
# 无论如何再跑一次:幂等补齐(例如后续版本新增了表/索引,
# 而老库的 user_version 已经是最新,就不会走 _migrate 了)
conn.executescript(_schema_sql())
ts = now_str()
# 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行,
# 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。
#
# 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的,
# 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落),
# 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。
for k, v in config.DEFAULTS.items():
conn.execute("INSERT OR IGNORE INTO settings(key,value,updated_at) VALUES(?,?,?)",
(k, v, ts))
if k in config.USER_EDITABLE_KEYS:
continue
conn.execute("INSERT OR IGNORE INTO settings(user_id,key,value,updated_at)"
" VALUES(0,?,?,?)", (k, v, ts))
# 顺手清掉历史遗留在实例级的凭证行(老版本曾把它们当实例级配置存过)
for k in sorted(config.USER_EDITABLE_KEYS):
conn.execute("DELETE FROM settings WHERE user_id=0 AND key=?", (k,))
# 顺手把历史明文凭证加密(幂等;新库无事可做)
enc = _encrypt_legacy_secrets(conn)
if enc:
migrated.append("明文凭证已加密:%s" % ", ".join(enc))
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "encrypt_secrets",
"明文凭证已加密:%s" % ", ".join(enc)[:400], "127.0.0.1"))
# 调度/采集参数在 v1.3.0 升为实例级:把管理员那份提升上去并清掉个人残留
promoted, cleaned = _promote_personal_to_global(conn)
if promoted or cleaned:
note = "配置作用域收敛:提升 %s 到实例级%s" % (
", ".join(promoted) if promoted else "(无)",
",清理 %d 条个人级残留" % cleaned if cleaned else "")
migrated.append(note)
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(0,?,?,?,?,?)",
(ts, "system", "promote_global_settings", note[:400], "127.0.0.1"))
if create_admin:
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n == 0:
from .security import hash_password
pwd = admin_password or "admin123"
conn.execute(
"INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,1,?)", (admin_user, hash_password(pwd), "管理员", ts))
"INSERT INTO users(username,password_hash,display_name,is_admin,status,"
" created_at) VALUES(?,?,?,1,'active',?)",
(admin_user, hash_password(pwd), "管理员", ts))
_initialized = True
return migrated
finally:
if own:
conn.close()
# ---------------- 配置读写 ----------------
def get_setting(conn, key, default=None):
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
if row is None or row["value"] is None:
return config.DEFAULTS.get(key, default)
return row["value"]
# ---------------- 配置读写(按作用域) ----------------
def get_setting(conn, key, default=None, uid=0):
"""取单个配置。
**加密键一律返回空串**:想拿 Cookie 明文只能用 get_secret(),
避免任何「顺手读一下配置」的代码把凭证带进日志或响应体。
"""
if key in config.ENCRYPTED_KEYS:
return ""
uid = 0 if config.is_global_key(key) else (uid or 0)
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid, key)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
if uid and key not in NO_FALLBACK_KEYS:
row = conn.execute("SELECT value FROM settings WHERE user_id=0 AND key=?",
(key,)).fetchone()
if row is not None and row["value"] is not None:
return row["value"]
return config.DEFAULTS.get(key, default)
def get_settings(conn, keys=None):
rows = conn.execute("SELECT key,value FROM settings").fetchall()
got = {r["key"]: r["value"] for r in rows}
def get_settings(conn, keys=None, uid=0):
"""取该账号的**有效配置**(DEFAULTS -> 实例级 -> 个人级 三级合并)。
Cookie 等加密键固定为空串,页面/接口可以直接整体回传。
"""
out = dict(config.DEFAULTS)
out.update(got)
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=0"):
out[r["key"]] = r["value"]
if uid:
for r in conn.execute("SELECT key,value FROM settings WHERE user_id=?", (uid,)):
if config.is_global_key(r["key"]):
continue # 个人作用域里不该有全局键,有也不认
out[r["key"]] = r["value"]
for k in config.ENCRYPTED_KEYS:
out[k] = ""
if keys:
return {k: out.get(k) for k in keys}
return out
def set_setting(conn, key, value):
conn.execute("INSERT INTO settings(key,value,updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, "" if value is None else str(value), now_str()))
def set_setting(conn, key, value, uid=0):
"""写单个配置。全局键强制落到 user_id=0;加密键自动加密后落库。"""
uid = 0 if config.is_global_key(key) else (uid or 0)
text = "" if value is None else str(value)
if key in config.ENCRYPTED_KEYS and text:
text = crypto.encrypt(text, config.encryption_key())
conn.execute("INSERT INTO settings(user_id,key,value,updated_at) VALUES(?,?,?,?) "
"ON CONFLICT(user_id,key) DO UPDATE SET value=excluded.value,"
" updated_at=excluded.updated_at", (uid, key, text, now_str()))
def set_settings(conn, pairs):
def set_settings(conn, pairs, uid=0):
for k, v in pairs.items():
set_setting(conn, k, v)
set_setting(conn, k, v, uid)
def get_int(conn, key, default=0):
def get_int(conn, key, default=0, uid=0):
try:
return int(float(get_setting(conn, key, default)))
return int(float(get_setting(conn, key, default, uid)))
except (TypeError, ValueError):
return default
def get_float(conn, key, default=0.0):
def get_float(conn, key, default=0.0, uid=0):
try:
return float(get_setting(conn, key, default))
return float(get_setting(conn, key, default, uid))
except (TypeError, ValueError):
return default
def get_bool(conn, key, default=False):
v = str(get_setting(conn, key, "1" if default else "0")).strip().lower()
def get_bool(conn, key, default=False, uid=0):
v = str(get_setting(conn, key, "1" if default else "0", uid)).strip().lower()
return v in ("1", "true", "yes", "on", "是")
# ---------------- 凭证(加密存储) ----------------
def get_secret(conn, key, uid=0):
"""取凭证明文。仅在真正要用它对外发请求时调用。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return ""
try:
return crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError as e:
raise SecretUnreadable("%s 无法解密:%s" % (key, e))
def set_secret(conn, key, value, uid=0):
set_setting(conn, key, value, uid)
def secret_state(conn, key, uid=0):
"""给界面用的凭证状态:只回「有没有 / 多少字符 / 尾部 4 位」,绝不含明文。"""
row = conn.execute("SELECT value FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
if row is None or not row["value"]:
return {"set": False, "chars": 0, "tail": "", "broken": False, "at": ""}
try:
plain = crypto.decrypt(row["value"], config.encryption_key())
except crypto.DecryptError:
return {"set": True, "chars": 0, "tail": "", "broken": True, "at": ""}
at = conn.execute("SELECT updated_at FROM settings WHERE user_id=? AND key=?",
(uid or 0, key)).fetchone()
return {"set": bool(plain), "chars": len(plain),
"tail": plain[-4:] if len(plain) >= 4 else "",
"broken": False, "at": (at["updated_at"] if at else "") or ""}
# ---------------- 账号 ----------------
def user_by_id(conn, uid):
return conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
def user_by_name(conn, username):
return conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
def active_users(conn):
"""启用状态的账号(调度器按人遍历)。"""
return conn.execute("SELECT * FROM users WHERE status='active' ORDER BY id").fetchall()
def user_count(conn):
return conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
# ---------------- 审计 ----------------
def audit(conn, action, actor=None, detail=None, ip=None):
conn.execute("INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)",
(now_str(), actor, action, detail, ip))
def audit(conn, action, actor=None, detail=None, ip=None, uid=0):
conn.execute("INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
" VALUES(?,?,?,?,?,?)", (uid or 0, now_str(), actor, action, detail, ip))
# ---------------- Flask 集成 ----------------
+80 -57
查看文件
@@ -6,12 +6,21 @@
返回结构刻意与旧版 dashboard/data/*.json 的字段保持一致(d/c/k/fc/bc/m/h、
id/c/m/cl/t/px …),这样 ECharts 大屏的渲染代码一行都不用改,只换数据来源。
**多用户约定(最重要)**
所有公开函数都要求显式传入 `uid`(归属账号),且 `uid` 是 `conn` 之后的
第一个位置参数、**没有默认值**。这是有意设计的:
忘记传 uid 会直接 TypeError,而不是静默把「全部人的数据」算进去。
聚合层内部一律通过 `_where(..., uid)` 把 `user_id = ?` 拼进 WHERE,
所以任何一条 SQL 都不可能跨账号取数。
"""
from datetime import datetime, timedelta
from . import config, db
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5 # /api/manifest 里对外的结构版本(多用户改版)
TOP_EXCERPT_LEN = 400
DEFAULT_TOP_N = 200
# 大屏页一次最多下发多少条窗口明细(页面要拿它在浏览器里算窗口 TOP / 散点)。
@@ -46,8 +55,9 @@ def norm_window(frm=None, to=None):
return f, t
def _where(frm=None, to=None, model=None, client=None, q=None):
w, p = [], []
def _where(frm=None, to=None, model=None, client=None, q=None, uid=0):
"""拼 WHERE。**user_id 永远在第一个条件上**,任何调用方都绕不过去。"""
w, p = ["user_id = ?"], [uid or 0]
if frm:
w.append("day >= ?")
p.append(frm)
@@ -63,7 +73,7 @@ def _where(frm=None, to=None, model=None, client=None, q=None):
if q:
w.append("(prompt LIKE ? OR request_id LIKE ?)")
p += ["%" + q + "%", "%" + q + "%"]
return ("WHERE " + " AND ".join(w)) if w else "", p
return "WHERE " + " AND ".join(w), p
def _excerpt(s, n):
@@ -74,8 +84,8 @@ def _excerpt(s, n):
# ---------------- 逐日聚合 ----------------
def daily(conn, frm=None, to=None, with_maps=True):
w, p = _where(frm, to)
def daily(conn, uid, frm=None, to=None, with_maps=True):
w, p = _where(frm, to, uid=uid)
days = {}
for r in conn.execute(
"SELECT day d, COUNT(*) k, ROUND(SUM(credits),2) c,"
@@ -101,8 +111,8 @@ def daily(conn, frm=None, to=None, with_maps=True):
# ---------------- 维度汇总 ----------------
def _dim(conn, col, frm=None, to=None):
w, p = _where(frm, to)
def _dim(conn, uid, col, frm=None, to=None):
w, p = _where(frm, to, uid=uid)
rows = conn.execute(
"SELECT %s name, COUNT(*) calls, ROUND(SUM(credits),2) credits,"
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) freeCalls,"
@@ -125,8 +135,8 @@ def _dim(conn, col, frm=None, to=None):
return out
def dims(conn, frm=None, to=None):
hours = {int(r["name"]): r for r in _dim(conn, "printf('%02d',hour)", frm, to)}
def dims(conn, uid, frm=None, to=None):
hours = {int(r["name"]): r for r in _dim(conn, uid, "printf('%02d',hour)", frm, to)}
hlist = []
for i in range(24):
h = "%02d" % i
@@ -135,14 +145,14 @@ def dims(conn, frm=None, to=None):
"lastDay": "", "avgPerCall": 0.0, "freeRate": 0.0}))
for i, o in enumerate(hlist):
o["name"] = "%02d" % i
return {"model": _dim(conn, "model", frm, to),
"client": _dim(conn, "client", frm, to),
return {"model": _dim(conn, uid, "model", frm, to),
"client": _dim(conn, uid, "client", frm, to),
"hour": hlist}
# ---------------- 单笔榜 ----------------
def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
w, p = _where(frm, to)
def top(conn, uid, frm=None, to=None, n=DEFAULT_TOP_N):
w, p = _where(frm, to, uid=uid)
items = []
for i, r in enumerate(conn.execute(
"SELECT request_id, credits, model, client, ts, prompt FROM usage_records %s"
@@ -154,8 +164,8 @@ def top(conn, frm=None, to=None, n=DEFAULT_TOP_N):
# ---------------- 明细(窗口内精简记录,不带 prompt 全文)----------------
def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
w, p = _where(frm, to)
def records(conn, uid, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first=False):
w, p = _where(frm, to, uid=uid)
order = "ORDER BY ts DESC, request_id DESC" if newest_first else "ORDER BY ts, request_id"
sql = ("SELECT request_id, credits, model, client, ts,"
" substr(replace(replace(COALESCE(prompt,''),char(10),' '),char(13),' '),1,?) px"
@@ -168,14 +178,15 @@ def records(conn, frm=None, to=None, excerpt=96, limit=0, offset=0, newest_first
"cl": r["client"], "t": r["ts"], "px": (r["px"] or "")} for r in conn.execute(sql, args)]
def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
def records_page(conn, uid, frm=None, to=None, model=None, client=None, q=None,
page=1, size=50, order="ts_desc", with_prompt=True):
frm, to = norm_window(frm, to)
size = max(1, min(int(size or 50), MAX_PAGE_SIZE))
page = max(1, int(page or 1))
w, p = _where(frm, to, model, client, q)
w, p = _where(frm, to, model, client, q, uid=uid)
total = conn.execute("SELECT COUNT(*) FROM usage_records %s" % w, p).fetchone()[0]
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s" % w, p).fetchone()
agg = conn.execute("SELECT ROUND(COALESCE(SUM(credits),0),2) c FROM usage_records %s"
% w, p).fetchone()
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
ob = orders.get(order, orders["ts_desc"])
@@ -195,11 +206,11 @@ def records_page(conn, frm=None, to=None, model=None, client=None, q=None,
"pages": max(1, (total + size - 1) // size), "items": items}
def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
def iter_records(conn, uid, frm=None, to=None, model=None, client=None, q=None,
order="ts_desc", with_prompt=True, batch=1000):
"""流式产出明细(给导出用):不把整个结果集读进内存。"""
frm, to = norm_window(frm, to)
w, p = _where(frm, to, model, client, q)
w, p = _where(frm, to, model, client, q, uid=uid)
orders = {"ts_desc": "ts DESC, request_id", "ts": "ts, request_id",
"credits_desc": "credits DESC, ts DESC", "credits": "credits, ts"}
ob = orders.get(order, orders["ts_desc"])
@@ -219,13 +230,14 @@ def iter_records(conn, frm=None, to=None, model=None, client=None, q=None,
# ---------------- 全局元信息 ----------------
def months(conn):
def months(conn, uid=0):
return [r[0] for r in conn.execute(
"SELECT DISTINCT substr(day,1,7) m FROM usage_records ORDER BY m")]
"SELECT DISTINCT substr(day,1,7) m FROM usage_records WHERE user_id=? ORDER BY m",
(uid or 0,))]
def totals(conn, frm=None, to=None):
w, p = _where(frm, to)
def totals(conn, uid, frm=None, to=None):
w, p = _where(frm, to, uid=uid)
r = conn.execute(
"SELECT COUNT(*) n, ROUND(COALESCE(SUM(credits),0),2) c,"
" SUM(CASE WHEN credits<=0 THEN 1 ELSE 0 END) fc,"
@@ -242,51 +254,60 @@ def totals(conn, frm=None, to=None):
"first": r["t0"] or "", "last": r["t1"] or ""}
def day_list(conn):
return [r[0] for r in conn.execute("SELECT DISTINCT day FROM usage_records ORDER BY day")]
def day_list(conn, uid=0):
return [r[0] for r in conn.execute(
"SELECT DISTINCT day FROM usage_records WHERE user_id=? ORDER BY day", (uid or 0,))]
def manifest(conn):
t = totals(conn)
def manifest(conn, uid):
"""该账号的数据清单 + 采集健康状态(凭证是否已配置 = 本人是否配了 Cookie)。"""
t = totals(conn, uid)
db_bytes = conn.execute("PRAGMA page_count").fetchone()[0] * \
conn.execute("PRAGMA page_size").fetchone()[0]
runs = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
last_run = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
health = db.get_setting(conn, "cookie", "")
mons = months(conn) # 只算一次(原来在返回体里调了两遍)
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?", (uid,)).fetchone()[0]
last_run = conn.execute("SELECT * FROM collect_runs WHERE user_id=?"
" ORDER BY id DESC LIMIT 1", (uid,)).fetchone()
cred = db.secret_state(conn, "cookie", uid)
mons = months(conn, uid) # 只算一次(原来在返回体里调了两遍)
return {
"schema": SCHEMA_VERSION,
"generated": db.now_str(),
"archive": "data/usage.sqlite",
"producer": "workbuddy-portal(Flask + SQLite)",
"note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果。",
"note": "数据正本为 SQLite 表 usage_records;daily/dims/top 均为 SQL 实时聚合结果,"
"且只统计当前登录账号的归属数据。",
"totals": {"records": t["records"], "credits": t["credits"], "calls": t["calls"],
"freeCalls": t["freeCalls"], "billableCalls": t["billableCalls"],
"days": day_list(conn), "months": mons,
"days": day_list(conn, uid), "months": mons,
"models": t["models"], "clients": t["clients"],
"first": t["first"], "last": t["last"],
"topCredits": (conn.execute("SELECT COALESCE(MAX(credits),0) FROM usage_records")
.fetchone()[0] or 0.0)},
"topCredits": (conn.execute(
"SELECT COALESCE(MAX(credits),0) FROM usage_records WHERE user_id=?",
(uid,)).fetchone()[0] or 0.0)},
"months": mons,
"sources": [
{"path": "usage_records", "role": "明细正本(SQLite 表)", "count": t["records"],
"bytes": db_bytes},
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)", "count": t["days"], "bytes": 0},
{"path": "usage_records", "role": "明细正本(SQLite 表,按账号隔离)",
"count": t["records"], "bytes": db_bytes},
{"path": "daily 聚合视图", "role": "逐日聚合(SQL GROUP BY day)",
"count": t["days"], "bytes": 0},
{"path": "dims 聚合视图", "role": "模型/客户端/时段汇总(SQL GROUP BY)",
"count": t["models"] + t["clients"] + 24, "bytes": 0},
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)", "count": DEFAULT_TOP_N, "bytes": 0},
{"path": "collect_runs", "role": "采集运行历史", "count": runs, "bytes": 0},
{"path": "top 查询", "role": "单笔消耗榜(ORDER BY credits DESC)",
"count": DEFAULT_TOP_N, "bytes": 0},
{"path": "collect_runs", "role": "采集运行历史(本账号)", "count": runs, "bytes": 0},
],
"focusDay": (last_run["win_to"] or "")[:10] if last_run else "",
"health": {"cookie": bool(health and health.strip()),
"health": {"cookie": cred["set"] and not cred["broken"],
"cookieChars": cred["chars"],
"cookieBroken": cred["broken"],
"lastRunAt": last_run["started_at"] if last_run else "",
"lastRunStatus": last_run["status"] if last_run else ""},
}
def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
def bundle(conn, uid, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
records_cap=BUNDLE_RECORDS_CAP):
"""大屏页一次请求拿齐所需数据。
"""大屏页一次请求拿齐所需数据(全部限定在当前账号内)。
窗口裁剪:records(明细)、dims(维度)、totals(KPI)随 frm/to 变化。
刻意不裁剪:daily(全量逐日,供日历与日期轴,体量小)、top(全局 TOP 榜)。
@@ -295,16 +316,16 @@ def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
避免存档长大后「全部」区间把整包明细都压到浏览器。
"""
frm, to = norm_window(frm, to)
tot = totals(conn, frm, to)
tot = totals(conn, uid, frm, to)
# 只有真的会超限时才改成「取最近 N 条」,避免改变现有正常路径的行为
truncated = tot["records"] > records_cap
recs = records(conn, frm, to, excerpt=excerpt,
recs = records(conn, uid, frm, to, excerpt=excerpt,
limit=records_cap if truncated else 0, newest_first=truncated)
return {
"manifest": manifest(conn),
"daily": daily(conn), # 全量逐日(体量小,供日历与日期轴)
"dims": dims(conn, frm, to), # 窗口内维度
"top": top(conn, None, None, top_n)["items"], # 全局 TOP 榜(对应「全局 TOP200」视图)
"manifest": manifest(conn, uid),
"daily": daily(conn, uid), # 全量逐日(体量小,供日历与日期轴)
"dims": dims(conn, uid, frm, to), # 窗口内维度
"top": top(conn, uid, None, None, top_n)["items"], # 该账号全局 TOP 榜
"records": recs,
"recordsTotal": tot["records"],
"recordsCap": records_cap,
@@ -315,7 +336,7 @@ def bundle(conn, frm=None, to=None, top_n=DEFAULT_TOP_N, excerpt=140,
# ---------------- 环比 ----------------
def summary(conn, frm, to):
def summary(conn, uid, frm, to):
"""KPI + 环比。前一段必须完整落在存档范围内,否则不给假数字。
frm/to 会先归一化(容错 '2026-09-08 12:00:00'、'2026/09/08' 等写法),
@@ -324,18 +345,19 @@ def summary(conn, frm, to):
frm, to = norm_window(frm, to)
if not frm or not to:
# 无法识别的日期:退化成全量口径,不抛异常(API 层会先校验并返回 400)
t = totals(conn)
t = totals(conn, uid)
frm, to = t["firstDay"], t["lastDay"]
if not frm or not to:
frm = to = datetime.now().strftime("%Y-%m-%d")
cur = totals(conn, frm, to)
cur = totals(conn, uid, frm, to)
days = (datetime.strptime(to, "%Y-%m-%d") - datetime.strptime(frm, "%Y-%m-%d")).days + 1
p_to = (datetime.strptime(frm, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
p_frm = (datetime.strptime(p_to, "%Y-%m-%d") - timedelta(days=days - 1)).strftime("%Y-%m-%d")
first_day = conn.execute("SELECT MIN(day) FROM usage_records").fetchone()[0]
first_day = conn.execute("SELECT MIN(day) FROM usage_records WHERE user_id=?",
(uid or 0,)).fetchone()[0]
prev = None
if first_day and p_frm >= first_day:
prev = totals(conn, p_frm, p_to)
prev = totals(conn, uid, p_frm, p_to)
out = dict(cur)
out["window"] = {"from": frm, "to": to, "days": days}
out["avgPerCall"] = round(cur["credits"] / cur["calls"], 4) if cur["calls"] else 0.0
@@ -350,7 +372,8 @@ def summary(conn, frm, to):
out["delta"] = None
# 残日:最后一天不是完整的一天
if to == datetime.now().strftime("%Y-%m-%d"):
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE day=?", (to,)).fetchone()
row = conn.execute("SELECT MAX(ts) FROM usage_records WHERE user_id=? AND day=?",
(uid or 0, to)).fetchone()
if row and row[0]:
out["partial"] = {"date": to, "hhmm": row[0][11:16]}
return out
+48 -28
查看文件
@@ -9,6 +9,13 @@
* 需要「启动补跑」(程序没开的时候错过了时刻,开机后要补上)
* 需要和 CLI 共享同一把文件锁,避免两处同时采集
多用户
------
调度配置(开关 / 时刻 / 补跑 / slot:* 簿记)都是**个人级**设置,
所以 tick 会遍历所有启用状态的账号,各自判断有没有到期槽位。
好处是「A 想 9 点采、B 想 21 点采」互不影响;代价是串行执行 ——
这是刻意的,SQLite 单写者不允许并发采集。
单实例保证:
* Flask 的 reloader 会 fork 两个进程 → 只在 WERKZEUG_RUN_MAIN 里启动
* 多进程部署时用环境变量 WB_DISABLE_SCHEDULER=1 关掉除一个之外的所有实例
@@ -22,7 +29,7 @@ from datetime import datetime, timedelta
from . import collect, db
log = logging.getLogger("wb.scheduler")
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期
SLOT_PREFIX = "slot:" # settings 键:slot:09:00 -> 最近执行的日期(按 user_id 存)
def parse_times(raw):
@@ -50,25 +57,25 @@ def parse_times(raw):
_parse_times = parse_times
def slots(conn):
return parse_times(db.get_setting(conn, "schedule_times"))
def slots(conn, uid=0):
return parse_times(db.get_setting(conn, "schedule_times", uid=uid))
def last_run_of_slot(conn, slot):
return db.get_setting(conn, SLOT_PREFIX + slot, "")
def last_run_of_slot(conn, uid, slot):
return db.get_setting(conn, SLOT_PREFIX + slot, "", uid)
def mark_slot(conn, slot, day):
db.set_setting(conn, SLOT_PREFIX + slot, day)
def mark_slot(conn, uid, slot, day):
db.set_setting(conn, SLOT_PREFIX + slot, day, uid)
def next_run_at(conn, now=None):
def next_run_at(conn, uid=0, now=None):
"""下一次计划执行时间(仅按配置推算,不含补跑)。"""
if not db.get_bool(conn, "schedule_enabled", True):
if not db.get_bool(conn, "schedule_enabled", True, uid):
return None
now = now or datetime.now()
best = None
for s in slots(conn):
for s in slots(conn, uid):
hh, mm = map(int, s.split(":"))
cand = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if cand <= now:
@@ -78,24 +85,24 @@ def next_run_at(conn, now=None):
return best
def due_slots(conn, now=None):
def due_slots(conn, uid=0, now=None):
"""返回此刻应当执行的槽位列表(含启动补跑)。"""
if not db.get_bool(conn, "schedule_enabled", True):
if not db.get_bool(conn, "schedule_enabled", True, uid):
return []
now = now or datetime.now()
today = now.strftime("%Y-%m-%d")
# 用 get_int 兜底:catch_up_grace_hours 在后台是自由文本框,
# 历史上填成 "12h" 会让这里 int() 抛 ValueError,把 /tasks 打成 500。
grace_hours = db.get_int(conn, "catch_up_grace_hours", 12)
grace_hours = db.get_int(conn, "catch_up_grace_hours", 12, uid)
grace = timedelta(hours=max(1, grace_hours))
catch_up = db.get_bool(conn, "catch_up", True)
catch_up = db.get_bool(conn, "catch_up", True, uid)
out = []
for s in slots(conn):
for s in slots(conn, uid):
hh, mm = map(int, s.split(":"))
when = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if when > now:
continue # 还没到点
if last_run_of_slot(conn, s) == today:
if last_run_of_slot(conn, uid, s) == today:
continue # 今天这个槽位已跑过
if when < now - grace and catch_up:
continue # 错过太久,不补(避免开机狂刷)
@@ -143,19 +150,32 @@ class Scheduler:
conn = db.thread_conn()
now = now or datetime.now()
today = now.strftime("%Y-%m-%d")
for slot in due_slots(conn, now):
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
second=0, microsecond=0)
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
log.info("触发采集:槽位 %s(%s)", slot, trigger)
mark_slot(conn, slot, today) # 先占位,避免采集失败被无限重试打爆云端
for u in db.active_users(conn):
uid = u["id"]
try:
r = collect.run_sync(trigger=trigger)
log.info("采集完成:%s", r["message"])
except collect.Busy as e:
log.warning("跳过(%s)", e)
except Exception as e:
log.error("采集失败:%s", e)
pending = due_slots(conn, uid, now)
except Exception as e: # 单个账号配置坏了不能拖垮其他人
log.error("账号 #%s(%s) 读取调度配置失败:%s", uid, u["username"], e)
continue
for slot in pending:
scheduled = now.replace(hour=int(slot[:2]), minute=int(slot[3:]),
second=0, microsecond=0)
trigger = "startup" if now - scheduled > timedelta(minutes=5) else "schedule"
log.info("触发采集:账号 %s 槽位 %s(%s)", u["username"], slot, trigger)
# 先占位,避免采集失败被无限重试打爆云端
mark_slot(conn, uid, slot, today)
if not db.secret_state(conn, "cookie", uid)["set"]:
log.info("跳过:账号 %s 还没配置自己的 Cookie", u["username"])
continue
try:
r = collect.run_sync(trigger=trigger, uid=uid)
log.info("采集完成:%s → %s", u["username"], r["message"])
except collect.Busy as e:
log.warning("跳过(%s)", e)
except db.SecretUnreadable as e:
log.error("账号 %s 的 Cookie 解不开:%s", u["username"], e)
except Exception as e:
log.error("账号 %s 采集失败:%s", u["username"], e)
return True
+62 -24
查看文件
@@ -1,15 +1,24 @@
-- WorkBuddy Portal —— SQLite 表结构
-- 设计要点:
-- * usage_records 是唯一正本,request_id 为主键,去重靠 ON CONFLICT,不再依赖内存比对
-- * 多用户:usage_records / collect_runs / audit_log 都带 user_id;
-- 每个账号只看得到自己的数据,管理员也不越过这条线(见 docs/ARCHITECTURE.md)
-- * settings 是 (user_id, key) 复合主键:user_id=0 为实例级,其余为个人级
-- * usage_records 主键是 (user_id, request_id):去重按「人 + 请求」,
-- 不同账号拿到相同 requestId 时互不覆盖
-- * ts 存「本地保留的最早开始时间」;cloud_ts 存云端最近一次返回的时间(观察长请求前移)
-- * day / hour 是冗余列,配合索引让区间扫描与 GROUP BY 都能走索引
-- * prompt 单独存一列且默认不参与任何列表接口(占传输量约 80%)
--
-- 升级:本文件是 DDL 的唯一来源。db._migrate() 用「改名旧表 -> 重跑本文件 ->
-- 回填数据 -> 删旧表 -> 再跑一次本文件补索引」的方式做在线迁移。
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
-- ---------------- 采集正本 ----------------
CREATE TABLE IF NOT EXISTS usage_records (
request_id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(usage_records.user_id -> users.id)
request_id TEXT NOT NULL,
ts TEXT NOT NULL, -- 'YYYY-MM-DD HH:MM:SS'
day TEXT NOT NULL, -- 'YYYY-MM-DD'
hour INTEGER NOT NULL, -- 0..23
@@ -19,19 +28,23 @@ CREATE TABLE IF NOT EXISTS usage_records (
prompt TEXT,
first_seen TEXT NOT NULL, -- 本地首次入库时间
last_seen TEXT NOT NULL, -- 本地最近一次见到的时间
cloud_ts TEXT -- 云端最近一次返回的 requestTime
cloud_ts TEXT, -- 云端最近一次返回的 requestTime
PRIMARY KEY (user_id, request_id)
);
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(day);
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(day, hour);
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(model, day);
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(client, day);
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(credits DESC);
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(ts);
-- 索引一律以 user_id 打头:所有查询都带「归属人」这个条件,
-- 少了它会退化成全表扫描(多用户下这是最容易踩的性能坑)。
CREATE INDEX IF NOT EXISTS idx_ur_day ON usage_records(user_id, day);
CREATE INDEX IF NOT EXISTS idx_ur_day_hour ON usage_records(user_id, day, hour);
CREATE INDEX IF NOT EXISTS idx_ur_model_day ON usage_records(user_id, model, day);
CREATE INDEX IF NOT EXISTS idx_ur_client_day ON usage_records(user_id, client, day);
CREATE INDEX IF NOT EXISTS idx_ur_credits ON usage_records(user_id, credits DESC);
CREATE INDEX IF NOT EXISTS idx_ur_ts ON usage_records(user_id, ts);
-- 采集运行历史(任务管理 + 日志管理的正本)
-- ---------------- 采集运行历史 ----------------
CREATE TABLE IF NOT EXISTS collect_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0, -- 哪次「谁」的采集
trigger TEXT NOT NULL, -- manual | schedule | cli | startup
status TEXT NOT NULL, -- running | ok | warn | error
started_at TEXT NOT NULL,
@@ -42,7 +55,7 @@ CREATE TABLE IF NOT EXISTS collect_runs (
fetched INTEGER DEFAULT 0, -- 云端返回条数
added INTEGER DEFAULT 0,
dup INTEGER DEFAULT 0,
total INTEGER DEFAULT 0, -- 入库后总条数
total INTEGER DEFAULT 0, -- 入库后该账号总条数
conflicts INTEGER DEFAULT 0,
exit_code INTEGER,
message TEXT, -- 一句话结论
@@ -50,34 +63,59 @@ CREATE TABLE IF NOT EXISTS collect_runs (
);
CREATE INDEX IF NOT EXISTS idx_runs_started ON collect_runs(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_runs_user ON collect_runs(user_id, id DESC);
-- 键值配置:cookie / user_agent / 调度时刻 / 采集参数 / 调度槽位去重标记
-- ---------------- 键值配置 ----------------
-- user_id = 0 : 实例级(接口基址、注册开关、验证码策略)
-- user_id > 0 : 个人级(自己填的 Cookie / UA、采集参数、调度时刻、slot:* 簿记)
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0,
key TEXT NOT NULL,
value TEXT,
updated_at TEXT
updated_at TEXT,
PRIMARY KEY (user_id, key)
);
-- 后台登录账号(局域网访问必须)
-- ---------------- 账号 ----------------
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT,
is_admin INTEGER NOT NULL DEFAULT 1,
email TEXT,
is_admin INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active', -- active | disabled
created_at TEXT,
register_ip TEXT, -- 自助注册来源,用于每日限额
last_login_at TEXT,
last_login_ip TEXT,
login_count INTEGER NOT NULL DEFAULT 0
);
-- 操作审计(登录、改配置、手动触发等)
-- ---------------- 操作审计 ----------------
-- 刻意**不记录任何凭证**:detail 里只写「改了哪些键」,不写键的值。
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0, -- 归属账号(0 = 系统 / CLI / 未登录事件)
at TEXT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log(at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id, id DESC);
-- ---------------- 图形验证码 ----------------
-- 答案只在服务端存在。下发到浏览器的只是 id,且用一次即删。
CREATE TABLE IF NOT EXISTS captchas (
id TEXT PRIMARY KEY, -- 随机 token(下发给客户端)
answer TEXT NOT NULL, -- 正确答案(绝不下发)
purpose TEXT NOT NULL, -- login | register
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captchas(expires_at);
+303 -80
查看文件
@@ -2,117 +2,211 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""密码哈希、登录装饰器、CSRF。
"""鉴权、口令策略、图形验证码、限速、CSRF、安全响应头。
局域网可访问 ⇒ 必须有鉴权。这里用 Werkzeug 自带的 PBKDF2,不引第三方依赖。
多用户下的三条红线
------------------
1. **Cookie(账号凭证)是按 user_id 分作用域存的**,读取一律走
`db.get_secret(conn, "cookie", uid)`;`db.get_settings()` 会把凭证置空,
所以「顺手把配置回传给前端」这类代码不可能把它带出去。
2. **禁用/删除账号立刻失效**:`current_user()` 每个请求回查一次
users.status,不靠会话过期来兜底(默认会话 12 小时,太久了)。
3. **失败限速按「来源 IP」和「用户名」双维度计数**:只按 IP 挡不住
「一批肉鸡轮流撞同一个账号」,只按用户名又会让一个 IP 无限注册。
"""
import functools
import hmac
import re
import secrets
import time
from flask import (current_app, flash, jsonify, redirect, render_template, request,
session, url_for)
from werkzeug.security import check_password_hash, generate_password_hash
from flask import (current_app, flash, g, jsonify, redirect, render_template,
request, session, url_for)
from . import config, db
from . import captcha, config, db
# 简易失败计数(内存即可:单进程部署,重启清零可接受)
_fails = {} # ip -> [count, first_ts]
_FAILS_MAX_IPS = 4096 # 上限,防止大量来源 IP 把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新的条目会被清理
# ---------------- 失败计数(内存即可) ----------------
# 单进程部署(见 README 的部署约束),重启清零可接受;
# 真正的防爆破靠「验证码 + 双维度限速」两道,而不是靠计数持久化。
_fails = {} # key -> [count, last_ts]
_FAILS_MAX_KEYS = 8192 # 上限,防止海量来源把字典撑爆
_FAILS_TTL = 3600 # 超过 1 小时无更新即清理
CAPTCHA_SESSION_PREFIX = "cap_"
def _prune_fails(now=None):
"""清掉过期条目;条目数超上限时按时间淘汰最旧的。"""
now = now or time.time()
dead = [ip for ip, c in _fails.items() if now - c[1] > _FAILS_TTL]
for ip in dead:
_fails.pop(ip, None)
if len(_fails) > _FAILS_MAX_IPS:
for ip, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_IPS]:
_fails.pop(ip, None)
dead = [k for k, c in _fails.items() if now - c[1] > _FAILS_TTL]
for k in dead:
_fails.pop(k, None)
if len(_fails) > _FAILS_MAX_KEYS:
for k, _ in sorted(_fails.items(), key=lambda kv: kv[1][1])[:len(_fails) - _FAILS_MAX_KEYS]:
_fails.pop(k, None)
def _ip_key(ip):
return "ip:" + (ip or "")
def _user_key(username):
return "user:" + (username or "").strip().lower()
def note_fail(key):
now = time.time()
_prune_fails(now)
c = _fails.get(key)
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
_fails[key] = [1, now]
return 1
c[0] += 1
c[1] = now
return c[0]
def is_locked(key):
c = _fails.get(key)
if not c or c[0] < config.MAX_LOGIN_FAILS:
return False
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
def clear_fail(key):
_fails.pop(key, None)
def lock_left(key):
c = _fails.get(key)
if not c:
return 0
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
def fail_count(key):
c = _fails.get(key)
return c[0] if c else 0
def auth_locked(ip, username=""):
"""返回还需锁定的秒数(0 = 未锁)。IP 与用户名任一超限即锁。"""
return max(lock_left(_ip_key(ip)), lock_left(_user_key(username)))
def note_auth_fail(ip, username=""):
n1 = note_fail(_ip_key(ip))
n2 = note_fail(_user_key(username)) if username else 0
return max(n1, n2)
def clear_auth_fail(ip, username=""):
clear_fail(_ip_key(ip))
if username:
clear_fail(_user_key(username))
# ---------------- 口令 / 用户名策略 ----------------
_USERNAME_RE = re.compile(config.USERNAME_RE)
def hash_password(p):
from werkzeug.security import generate_password_hash
return generate_password_hash(p, method="pbkdf2:sha256:200000")
def verify_password(hashed, p):
from werkzeug.security import check_password_hash
try:
return check_password_hash(hashed, p)
except (ValueError, TypeError):
return False
def username_problem(name):
"""校验用户名。开放注册后这是第一个入口,必须收紧。"""
name = (name or "").strip()
if not name:
return "用户名必填"
if not _USERNAME_RE.match(name):
return "用户名需 3~32 位,以字母或数字开头,只能用字母、数字、下划线、点、连字符"
if name.lower() in ("admin", "administrator", "root", "system", "guest", "null"):
return "该用户名为系统保留字,请换一个"
return None
def password_problem(new, new2=None, username=None):
"""口令强度:8 位以上,且至少包含两类字符。
比原来的「只要 6 位」严格——因为现在任何人都能自助注册,
弱口令直接决定了整个实例的抗爆破能力。
"""
new = new or ""
if len(new) < config.PASSWORD_MIN:
return "密码至少 %d 位" % config.PASSWORD_MIN
if len(new) > config.PASSWORD_MAX:
return "密码过长(上限 %d 位)" % config.PASSWORD_MAX
classes = sum(bool(re.search(p, new)) for p in
(r"[a-z]", r"[A-Z]", r"[0-9]", r"[^A-Za-z0-9]"))
if classes < 2:
return "密码需包含大写字母、小写字母、数字、符号中的至少两类"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
if username and new.lower() == str(username).lower():
return "密码不能与用户名相同"
return None
# 兼容旧名(原来的 api.py 内部函数)
_check_password = password_problem
# ---------------- 登录 ----------------
def login_ok(conn, username, password):
"""校验口令。返回 (user_row, error_message)。
停用账号与口令错误返回**同一句话**,避免探测哪些用户名存在
(不过自助注册本身就暴露了用户名唯一性,这里只是不打额外的广告)。
"""
username = (username or "").strip()
row = conn.execute("SELECT * FROM users WHERE username=?", (username,)).fetchone()
if row is None or not verify_password(row["password_hash"], password):
return None
return None, "用户名或密码不正确"
if (row["status"] or "active") != "active":
return None, "该账号已被停用,请联系管理员"
conn.execute("UPDATE users SET last_login_at=?, login_count=login_count+1 WHERE id=?",
(db.now_str(), row["id"]))
return row
# ---------------- 跳转目标白名单(防开放重定向) ----------------
def safe_next(target, fallback="/"):
"""只允许站内相对路径。
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
"""
if not target:
return fallback
t = str(target).strip()
if not t.startswith("/"):
return fallback
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
return fallback
# 去重斜杠后仍以 // 开头的(如 "/\t/evil")一并拒绝
if t.lstrip("/").startswith("//"):
return fallback
if "\r" in t or "\n" in t:
return fallback
return t
# ---------------- 登录失败限速 ----------------
def note_fail(ip):
now = time.time()
_prune_fails(now)
c = _fails.get(ip)
if c is None or now - c[1] > config.LOGIN_LOCK_MINUTES * 60:
_fails[ip] = [1, now]
return 1
c[0] += 1
return c[0]
def is_locked(ip):
c = _fails.get(ip)
if not c or c[0] < config.MAX_LOGIN_FAILS:
return False
return time.time() - c[1] <= config.LOGIN_LOCK_MINUTES * 60
def clear_fail(ip):
_fails.pop(ip, None)
def lock_left(ip):
c = _fails.get(ip)
if not c:
return 0
return max(0, int(config.LOGIN_LOCK_MINUTES * 60 - (time.time() - c[1])))
return row, None
# ---------------- 会话 ----------------
def current_user():
"""当前登录用户(dict)或 None。
每个请求回查一次 users 表:账号被停用/删除后**立刻**失效,
而不是等 12 小时会话自然过期。结果缓存在 flask.g 里,一次请求只查一次。
"""
if "wb_user" in g:
return g.wb_user
uid = session.get("uid")
if not uid:
return None
return {"id": uid, "username": session.get("uname"), "display_name": session.get("dname"),
"is_admin": bool(session.get("adm", 1))}
user = None
if uid:
try:
row = db.get_db().execute(
"SELECT id,username,display_name,is_admin,status FROM users WHERE id=?",
(uid,)).fetchone()
except Exception: # noqa: BLE001 (无请求上下文等)
row = None
if row is None or (row["status"] or "active") != "active":
session.clear()
else:
user = {"id": row["id"], "username": row["username"],
"display_name": row["display_name"] or row["username"],
"is_admin": bool(row["is_admin"])}
# 个人信息(显示名)改过之后立即生效,不必重新登录
session["dname"] = user["display_name"]
session["adm"] = 1 if user["is_admin"] else 0
g.wb_user = user
return user
def is_admin():
@@ -121,14 +215,17 @@ def is_admin():
def login_session(user):
"""建立登录会话。
`session.clear()` 是必须的:既清掉前一次的残留,
也顺带换掉 CSRF token 与验证码 id —— 这正是防「会话固定」的做法。
"""
session.clear()
session["uid"] = user["id"]
session["uname"] = user["username"]
session["dname"] = user["display_name"] or user["username"]
try:
session["adm"] = 1 if user["is_admin"] else 0
except (KeyError, IndexError, TypeError):
session["adm"] = 1
session["adm"] = 1 if user["is_admin"] else 0
session["login_at"] = db.now_str()
session.permanent = True
@@ -167,6 +264,100 @@ def admin_required(fn):
return wrapper
# ---------------- 跳转目标白名单(防开放重定向) ----------------
def safe_next(target, fallback="/"):
"""只允许站内相对路径。
`//evil.com`、`/\\evil.com`、`https://evil.com` 都必须拒绝:
`//` 开头是协议相对 URL,浏览器会把 `//evil.com` 当成外站跳转。
"""
if not target:
return fallback
t = str(target).strip()
if not t.startswith("/"):
return fallback
if t.startswith("//") or t.startswith("/\\") or "\\" in t:
return fallback
if t.lstrip("/").startswith("//"):
return fallback
if "\r" in t or "\n" in t:
return fallback
return t
# ---------------- 图形验证码 ----------------
def captcha_required(conn, ip, username=""):
"""按 captcha_policy 决定本次是否需要验证码。"""
policy = (db.get_setting(conn, "captcha_policy", "always") or "always").strip().lower()
if policy == "off":
return False
if policy == "adaptive":
# 「自适应」= 这个来源出过问题才要求,日常登录不打扰
return (fail_count(_ip_key(ip)) >= 2
or (username and fail_count(_user_key(username)) >= 2))
return True # always(默认)
def issue_captcha(conn, purpose):
"""新建验证码并把 id 记进会话,返回 PNG 字节。答案绝不离开服务端。"""
try:
length = int(db.get_setting(conn, "captcha_length", 4) or 4)
except (TypeError, ValueError):
length = 4
length = max(4, min(6, length))
cid, code = captcha.create(conn, purpose, length=length)
session[CAPTCHA_SESSION_PREFIX + purpose] = cid
return captcha.render(code, width=150 if length <= 4 else 150 + (length - 4) * 32)
def consume_captcha(conn, purpose, answer):
"""校验并作废本次验证码。会话里的 id 一并丢掉,逼迫下次换一张新图。"""
cid = session.pop(CAPTCHA_SESSION_PREFIX + purpose, None)
return captcha.verify(conn, cid, (answer or "").strip().upper(), purpose)
# 验证码出图限速:出图本身要做点阵渲染 + zlib,不设限就是一条廉价的
# CPU/带宽放大路径(有人拿它当免费的图片生成器刷)。
_cap_fetch = {} # ip -> [count, window_started_at]
_CAP_FETCH_MAX = 40 # 每窗口最多出图张数
_CAP_FETCH_WINDOW = 60 # 窗口长度(秒)
def captcha_fetch_allowed(ip):
now = time.time()
cur = _cap_fetch.get(ip)
if cur is None or now - cur[1] > _CAP_FETCH_WINDOW:
if len(_cap_fetch) > _FAILS_MAX_KEYS:
_cap_fetch.clear()
_cap_fetch[ip] = [1, now]
return True
cur[0] += 1
return cur[0] <= _CAP_FETCH_MAX
def audit_login_fail(conn, username, detail, ip):
"""登录失败审计。
`user_id` 留 0:此时还不能确定是谁(可能是有人在撞别人的账号),
但 `actor` 记下被尝试的用户名,便于事后按人名检索。
"""
db.audit(conn, "login_failed", username or "-", detail, ip, 0)
# ---------------- 注册开关与配额 ----------------
def register_allowed(conn):
return db.get_bool(conn, "allow_register", True)
def register_quota(conn, ip):
"""同一 IP 当天的注册配额。返回 (是否允许, 已注册数, 上限)。"""
limit = db.get_int(conn, "register_max_per_ip", 3)
today = db.now_str()[:10]
n = conn.execute("SELECT COUNT(*) FROM users WHERE register_ip=?"
" AND substr(COALESCE(created_at,''),1,10)=?", (ip, today)).fetchone()[0]
return n < limit, n, limit
# ---------------- CSRF ----------------
def csrf_token():
t = session.get("_csrf")
@@ -182,11 +373,39 @@ def check_csrf():
sent = request.form.get("_csrf") or request.headers.get("X-CSRF-Token") or ""
if not sent or not hmac.compare_digest(sent, session.get("_csrf", "")):
if wants_json():
return jsonify({"ok": False, "error": "csrf", "message": "CSRF 校验失败,请刷新页面"}), 400
return jsonify({"ok": False, "error": "csrf",
"message": "CSRF 校验失败,请刷新页面"}), 400
return "CSRF 校验失败,请刷新页面后重试", 400
return None
# ---------------- 安全响应头 ----------------
# 这些头是「纵深防御」:本项目的输出都过了 Jinja 自动转义 + app.js 手动转义,
# 但多一层 nosniff / frame-ancestors 能让「某处漏转义」不至于直接变成可利用的 XSS。
CSP = ("default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self' 'unsafe-inline'; "
"connect-src 'self'; "
"font-src 'self' data:; "
"object-src 'none'; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'")
def apply_security_headers(resp):
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("X-Frame-Options", "DENY")
# 不让站内 URL(可能含 next=、run= 等参数)随外链 referer 泄漏出去
resp.headers.setdefault("Referrer-Policy", "same-origin")
resp.headers.setdefault("Content-Security-Policy", CSP)
resp.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
if request.path.startswith("/api/") or request.path.startswith("/captcha"):
resp.headers.setdefault("Cache-Control", "no-store")
return resp
def init_app(app):
app.jinja_env.globals["csrf_token"] = csrf_token
app.jinja_env.globals["current_user"] = current_user
@@ -194,3 +413,7 @@ def init_app(app):
@app.before_request
def _guard():
return check_csrf()
@app.after_request
def _headers(resp):
return apply_security_headers(resp)
+283 -106
查看文件
@@ -9,23 +9,43 @@
* 参数 from/to 为 'YYYY-MM-DD';缺省则不限(即全量)
* 列表类接口默认不返回 prompt 全文(占传输量约 80%),只有 /api/top 与
/api/records/<request_id> 会带
**多用户约定**
每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid,
所以这里漏传会直接 TypeError(而不是静默返回全量)。
写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断):
* 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent);
* 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写,
任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。
"""
import os
from datetime import datetime
from flask import Blueprint, jsonify, request
from .. import collect, config, db, query, scheduler
from ..security import admin_required, current_user, login_required
from .. import collect, config, db, query, scheduler, security
from ..security import admin_required, current_user, is_admin, login_required
bp = Blueprint("api", __name__, url_prefix="/api")
def _uid():
u = current_user()
return u["id"] if u else 0
def _arg(name, default=None):
v = request.args.get(name)
return v if v not in (None, "") else default
def _json_body():
body = request.get_json(silent=True)
return body if isinstance(body, dict) else {}
class BadParam(ValueError):
"""查询参数不合法 -> 由 __init__ 的 ValueError 处理器统一转成 400。"""
@@ -60,7 +80,13 @@ def _int(name, default, lo=1, hi=2000):
@bp.get("/manifest")
@login_required
def api_manifest():
return jsonify(query.manifest(db.get_db()))
u = current_user()
m = query.manifest(db.get_db(), u["id"])
# 角色标记只在这里补:大屏是**静态页**,拿不到 Jinja 上下文,
# 只能靠数据接口知道自己该不该渲染「日志管理」这类管理员入口。
# 前端隐藏只是不给死链,真正的闸门始终是服务端的 @admin_required。
m["role"] = "admin" if u["is_admin"] else "user"
return jsonify(m)
@bp.get("/bundle")
@@ -68,31 +94,33 @@ def api_manifest():
def api_bundle():
"""大屏页一次拿齐:全量 daily + 窗口 dims/top/records。"""
frm, to = _win()
return jsonify(query.bundle(db.get_db(), frm, to, top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
return jsonify(query.bundle(db.get_db(), _uid(), frm, to,
top_n=_int("topN", query.DEFAULT_TOP_N, 1, 1000)))
@bp.get("/summary")
@login_required
def api_summary():
conn = db.get_db()
uid = _uid()
frm, to = _win()
if not frm or not to:
t = query.totals(conn)
t = query.totals(conn, uid)
frm, to = t["firstDay"], t["lastDay"]
return jsonify(query.summary(conn, frm, to))
return jsonify(query.summary(conn, uid, frm, to))
@bp.get("/daily")
@login_required
def api_daily():
return jsonify({"days": query.daily(db.get_db(), *_win())})
return jsonify({"days": query.daily(db.get_db(), _uid(), *_win())})
@bp.get("/dims")
@login_required
def api_dims():
conn = db.get_db()
d = query.dims(conn, *_win())
d = query.dims(conn, _uid(), *_win())
dim = _arg("dim")
if dim in d:
return jsonify({dim: d[dim]})
@@ -103,17 +131,18 @@ def api_dims():
@login_required
def api_top():
conn = db.get_db()
return jsonify(query.top(conn, *_win(), n=_int("n", 50, 1, 1000)))
return jsonify(query.top(conn, _uid(), *_win(), n=_int("n", 50, 1, 1000)))
@bp.get("/records")
@login_required
def api_records():
conn = db.get_db()
uid = _uid()
frm, to = _win()
page = _int("page", 1, 1, 100000)
size = _int("size", 50, 1, 500)
r = query.records_page(conn, frm, to, model=_arg("model"), client=_arg("client"),
r = query.records_page(conn, uid, frm, to, model=_arg("model"), client=_arg("client"),
q=_arg("q"), page=page, size=size, order=_arg("order", "ts_desc"),
with_prompt=False if _arg("lean") == "1" else True)
return jsonify(r)
@@ -122,8 +151,9 @@ def api_records():
@bp.get("/records/<request_id>")
@login_required
def api_record(request_id):
row = db.get_db().execute(
"SELECT * FROM usage_records WHERE request_id=?", (request_id,)).fetchone()
# user_id 必须进 WHERE:否则改一个 URL 就能读到别人的 Prompt 全文
row = db.get_db().execute("SELECT * FROM usage_records WHERE user_id=? AND request_id=?",
(_uid(), request_id)).fetchone()
if row is None:
return jsonify({"ok": False, "message": "记录不存在"}), 404
return jsonify(dict(row))
@@ -135,14 +165,16 @@ def api_runs():
conn = db.get_db()
rows = conn.execute("SELECT id,trigger,status,started_at,finished_at,duration_ms,win_from,"
"win_to,fetched,added,dup,total,conflicts,exit_code,message"
" FROM collect_runs ORDER BY id DESC LIMIT ?", (_int("limit", 50, 1, 500),))
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT ?",
(_uid(), _int("limit", 50, 1, 500)))
return jsonify({"items": [dict(r) for r in rows]})
@bp.get("/runs/<int:run_id>")
@login_required
def api_run(run_id):
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=?", (run_id,)).fetchone()
row = db.get_db().execute("SELECT * FROM collect_runs WHERE id=? AND user_id=?",
(run_id, _uid())).fetchone()
if row is None:
return jsonify({"ok": False, "message": "运行记录不存在"}), 404
return jsonify(dict(row))
@@ -152,23 +184,37 @@ def api_run(run_id):
@login_required
def api_status():
conn = db.get_db()
u = current_user()
uid = u["id"]
sch = scheduler.get_scheduler()
nxt = scheduler.next_run_at(conn)
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE status='running'").fetchone()[0]
nxt = scheduler.next_run_at(conn, uid)
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(uid,)).fetchone()
running = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=? AND status='running'",
(uid,)).fetchone()[0]
cred = db.secret_state(conn, "cookie", uid)
return jsonify({
"server_time": db.now_str(),
# 角色能力:大屏等**静态页**拿不到 Jinja 上下文,只能靠这个字段
# 决定要不要渲染管理员专属入口(如「日志管理」)。服务端仍会
# 对这些入口再做一次鉴权,前端隐藏只是为了不给出误导性的按钮。
"is_admin": bool(u["is_admin"]),
"can_edit_schedule": bool(u["is_admin"]),
"can_view_logs": bool(u["is_admin"]),
"scheduler": {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True),
"times": scheduler.slots(conn),
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
"times": scheduler.slots(conn, uid),
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
"catch_up": db.get_bool(conn, "catch_up", True),
"catch_up": db.get_bool(conn, "catch_up", True, uid),
"lockfile": os.path.exists(collect.LOCK_PATH),
},
"running_runs": running,
"last_run": dict(last) if last else None,
"cookie_set": bool((db.get_setting(conn, "cookie") or "").strip()),
# 只回「有没有配」与字符数,绝不回凭证内容
"cookie_set": bool(cred["set"] and not cred["broken"]),
"cookie_chars": cred["chars"],
"cookie_broken": cred["broken"],
})
@@ -176,9 +222,8 @@ def api_status():
@login_required
def api_collect():
"""手动触发一次采集(后台线程之外同步执行,页面等待结果)。"""
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
u = current_user()
body = _json_body()
frm, to = body.get("from"), body.get("to")
try:
kw = {}
@@ -194,40 +239,55 @@ def api_collect():
kw["to_dt"] = datetime.strptime(d, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
if kw.get("from_dt") and kw.get("to_dt") and kw["from_dt"] > kw["to_dt"]:
raise BadParam("起始日期不能晚于结束日期")
r = collect.run_sync(trigger="manual", **kw)
r = collect.run_sync(trigger="manual", uid=u["id"], **kw)
except BadParam as e:
return jsonify({"ok": False, "error": "bad_request", "message": str(e)}), 400
except collect.Busy as e:
return jsonify({"ok": False, "error": "busy", "message": str(e)}), 409
except collect.NotReady as e:
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
except db.SecretUnreadable as e:
return jsonify({"ok": False, "error": "cookie_broken",
"message": "已保存的 Cookie 无法解密(实例密钥被更换过):%s。"
"请到「配置管理」重新粘贴。" % e}), 409
except collect.ApiError as e:
code = 401 if e.cookie_expired else 502
return jsonify({"ok": False, "error": "cookie_expired" if e.cookie_expired else "api",
"message": str(e)}), code
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(db.get_db(), "collect", (current_user() or {}).get("username"), r["message"],
request.remote_addr)
db.audit(db.get_db(), "collect", u["username"], r["message"], request.remote_addr, u["id"])
return jsonify({"ok": True, "result": r})
@bp.get("/audit")
@login_required
def api_audit():
"""操作审计分页(日志管理页用;原来只能看最近 40 条)。"""
"""操作审计分页。管理员看全部(便于追责),普通用户只看自己触发的。"""
conn = db.get_db()
u = current_user()
action = _arg("action")
page = _int("page", 1, 1, 100000)
size = _int("size", 50, 1, 500)
w, p = "", []
w, p = [], []
if not u["is_admin"]:
w.append("user_id = ?")
p.append(u["id"])
if action:
w, p = "WHERE action = ?", [action]
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % w, p).fetchone()[0]
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % w,
w.append("action = ?")
p.append(action)
ws = ("WHERE " + " AND ".join(w)) if w else ""
total = conn.execute("SELECT COUNT(*) FROM audit_log %s" % ws, p).fetchone()[0]
rows = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % ws,
p + [size, (page - 1) * size])
# 动作清单不带 action 条件,否则只剩下当前那一个动作可选
base_p = [u["id"]] if not u["is_admin"] else []
base = "WHERE user_id = ?" if not u["is_admin"] else ""
actions = [r[0] for r in conn.execute(
"SELECT DISTINCT action FROM audit_log ORDER BY action")]
"SELECT DISTINCT action FROM audit_log %s ORDER BY action" % base, base_p)]
return jsonify({"total": total, "page": page, "size": size,
"pages": max(1, (total + size - 1) // size),
"scope": "all" if u["is_admin"] else "self",
"actions": actions,
"items": [dict(r) for r in rows]})
@@ -236,13 +296,26 @@ def api_audit():
@bp.post("/maintenance/<action>")
@login_required
def api_maintenance(action):
"""把 CLI 里的维护动作搬到页面上:补全 prompt / VACUUM / 导出 CSV。"""
"""把 CLI 里的维护动作搬到页面上。
只有 `vacuum` 是**实例级**动作(整个库一起整理),所以它仅管理员可用;
其余三个都只作用于当前账号自己的数据。
"""
conn = db.get_db()
user = (current_user() or {}).get("username")
u = current_user()
uid = u["id"]
if action == "vacuum" and not u["is_admin"]:
return jsonify({"ok": False, "error": "forbidden",
"message": "数据库整理是整库操作,仅管理员可执行"}), 403
try:
if action == "fill-prompt":
try:
n = collect.fill_prompt(conn, log=lambda m: None)
n = collect.fill_prompt(conn, uid, log=lambda m: None)
except collect.NotReady as e:
return jsonify({"ok": False, "error": "no_cookie", "message": str(e)}), 409
except db.SecretUnreadable as e:
return jsonify({"ok": False, "error": "cookie_broken",
"message": "已保存的 Cookie 无法解密,请重新粘贴:%s" % e}), 409
except collect.ApiError as e:
return jsonify({"ok": False, "error": "api", "message": str(e)}), 502
msg = "补全 %d 条 User Prompt" % n
@@ -250,19 +323,20 @@ def api_maintenance(action):
before = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.execute("VACUUM")
conn.execute("PRAGMA optimize")
after = os.path.getsize(config.SQLITE_PATH) if os.path.exists(config.SQLITE_PATH) else 0
msg = "数据库整理完成:%s → %s" % (_human(before), _human(after))
elif action == "export-csv":
path, n = collect.export_csv(conn)
path, n = collect.export_csv(conn, uid, username=u["username"])
msg = "已导出 %d 条到 %s" % (n, os.path.relpath(path, config.BASE_DIR))
elif action == "recount":
n = collect.record_count(conn)
n = collect.record_count(conn, uid)
msg = "存档当前 %d 条记录" % n
else:
return jsonify({"ok": False, "error": "unknown", "message": "未知维护动作"}), 404
except Exception as e: # noqa: BLE001
return jsonify({"ok": False, "error": "internal", "message": str(e)}), 500
db.audit(conn, "maintenance:" + action, user, msg, request.remote_addr)
db.audit(conn, "maintenance:" + action, u["username"], msg, request.remote_addr, uid)
return jsonify({"ok": True, "message": msg})
@@ -276,16 +350,20 @@ def _human(n):
@bp.get("/settings")
@login_required
def api_settings_get():
"""当前账号的**有效配置**(不含任何凭证明文)。"""
conn = db.get_db()
s = db.get_settings(conn)
if (s.get("cookie") or "").strip():
s["cookie_hint"] = "%d 字符,…%s" % (len(s["cookie"]), s["cookie"][-12:])
else:
s["cookie_hint"] = ""
s.pop("cookie", None) # 不回传明文凭证
u = current_user()
s = db.get_settings(conn, uid=u["id"])
st = db.secret_state(conn, "cookie", u["id"])
s["cookie_hint"] = ("%d 字符,…%s" % (st["chars"], st["tail"])) if st["set"] else ""
s["cookie_broken"] = st["broken"]
# 内部簿记键(slot:09:00 这类调度槽位标记)不属于配置项,绝不外泄
for k in [k for k in list(s) if config.is_internal_key(k)]:
s.pop(k, None)
s["_globalKeys"] = sorted(config.GLOBAL_KEYS)
s["_userKeys"] = sorted(config.USER_EDITABLE_KEYS)
s["_canEditGlobal"] = bool(u["is_admin"])
s["_role"] = "admin" if u["is_admin"] else "user"
return jsonify(s)
@@ -293,137 +371,210 @@ def api_settings_get():
@login_required
def api_settings_post():
conn = db.get_db()
body = request.get_json(silent=True) or {}
if not isinstance(body, dict):
return jsonify({"ok": False, "message": "请求体必须是对象"}), 400
changed, errors, ignored = [], [], []
u = current_user()
uid = u["id"]
body = _json_body()
changed, errors, ignored, denied = [], [], [], []
for k, v in body.items():
if config.is_internal_key(k):
ignored.append(k)
continue # slot:* 是调度簿记,不允许前台写
if k not in config.DEFAULTS:
errors.append("未知配置项:%s" % k)
continue
if not config.writable_by(k, u["is_admin"]):
# 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 ——
# 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员:
# 否则任意注册用户就能把大家的数据采集指向别的服务器,
# 或者把 page_size 调到 1000 去 hammer 云端接口。
denied.append(k)
continue
if k == "cookie":
if not str(v).strip():
raw = str(v).strip()
if not raw:
continue # 空值不动,避免误清
if str(v).strip().lower() in ("__clear__", "-"):
db.set_setting(conn, "cookie", "")
if raw.lower() in ("__clear__", "-"):
db.set_secret(conn, "cookie", "", uid)
changed.append(k)
continue
val, err = config.normalize_setting(k, v)
if err:
errors.append(err)
continue
db.set_setting(conn, k, val)
if k in config.ENCRYPTED_KEYS:
db.set_secret(conn, k, val, uid)
else:
db.set_setting(conn, k, val, uid)
changed.append(k)
if denied:
errors.append("以下配置仅管理员可修改,本账号无法保存:%s。"
"普通账号可以维护的是本人凭证(Cookie / User-Agent)。"
% "、".join(sorted(denied)))
if errors:
db.audit(conn, "settings_rejected", (current_user() or {}).get("username"),
";".join(errors)[:500], request.remote_addr)
db.audit(conn, "settings_rejected", u["username"], ";".join(errors)[:500],
request.remote_addr, uid)
return jsonify({"ok": False, "error": "invalid", "message": ";".join(errors),
"errors": errors, "changed": sorted(changed)}), 400
# 调整调度配置后清掉槽位标记,让新时刻立即生效
if {"schedule_times", "schedule_enabled"} & set(changed):
conn.execute("DELETE FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
db.audit(conn, "settings", (current_user() or {}).get("username"),
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr)
# 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。
# 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内
# 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。
if "schedule_times" in changed:
keep = set(scheduler.slots(conn, 0))
stale = [(r["user_id"], r["key"]) for r in conn.execute(
"SELECT user_id,key FROM settings WHERE key LIKE ?", (scheduler.SLOT_PREFIX + "%",))
if r["key"][len(scheduler.SLOT_PREFIX):] not in keep]
for row_uid, skey in stale:
conn.execute("DELETE FROM settings WHERE user_id=? AND key=?", (row_uid, skey))
db.audit(conn, "settings", u["username"],
"修改:" + (",".join(sorted(changed)) or "(无变化)"), request.remote_addr, uid)
return jsonify({"ok": True, "changed": sorted(changed), "ignored": sorted(ignored)})
@bp.post("/password")
@login_required
def api_password():
from ..security import hash_password, verify_password
conn = db.get_db()
body = request.get_json(silent=True) or {}
u = current_user()
body = _json_body()
row = conn.execute("SELECT * FROM users WHERE id=?", (u["id"],)).fetchone()
if row is None or not verify_password(row["password_hash"], body.get("old") or ""):
if row is None or not security.verify_password(row["password_hash"], body.get("old") or ""):
return jsonify({"ok": False, "message": "原密码不正确"}), 400
new = (body.get("new") or "").strip()
err = _check_password(new, body.get("new2"))
err = security.password_problem(new, body.get("new2"), u["username"])
if err:
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(new), u["id"]))
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr)
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(new), u["id"]))
db.audit(conn, "password", u["username"], "修改登录密码", request.remote_addr, u["id"])
return jsonify({"ok": True, "message": "密码已更新"})
# ---------------- 用户管理(原来只有 CLI passwd) ----------------
def _check_password(new, new2=None):
if len(new or "") < 6:
return "密码至少 6 位"
if len(new) > 128:
return "密码过长(上限 128 位)"
if new2 is not None and new2 != new:
return "两次输入的新密码不一致"
return None
@bp.post("/profile")
@login_required
def api_profile():
"""自助修改个人资料(显示名 / 邮箱)。用户名不可改 —— 它是审计里的主键。"""
conn = db.get_db()
u = current_user()
body = _json_body()
changed = []
if "display_name" in body:
name = (body.get("display_name") or "").strip()[:64] or u["username"]
conn.execute("UPDATE users SET display_name=? WHERE id=?", (name, u["id"]))
changed.append("显示名")
if "email" in body:
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
if email and ("@" not in email or " " in email):
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, u["id"]))
changed.append("邮箱")
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "profile", u["username"], "修改:" + "、".join(changed),
request.remote_addr, u["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
# ---------------- 用户管理(管理员) ----------------
def _user_public(r):
"""用户行 -> 可下发结构。**绝不包含口令散列,也不包含任何凭证。**"""
return {"id": r["id"], "username": r["username"], "display_name": r["display_name"],
"email": r["email"], "is_admin": bool(r["is_admin"]),
"status": r["status"] or "active", "created_at": r["created_at"],
"register_ip": r["register_ip"], "last_login_at": r["last_login_at"],
"last_login_ip": r["last_login_ip"], "login_count": r["login_count"]}
@bp.get("/users")
@admin_required
def api_users():
rows = db.get_db().execute(
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
" FROM users ORDER BY id").fetchall()
return jsonify({"items": [dict(r) for r in rows]})
rows = db.get_db().execute("SELECT * FROM users ORDER BY id").fetchall()
return jsonify({"items": [_user_public(r) for r in rows]})
@bp.post("/users")
@admin_required
def api_user_create():
from ..security import hash_password
conn = db.get_db()
body = request.get_json(silent=True) or {}
me = current_user()
body = _json_body()
name = (body.get("username") or "").strip()
pwd = (body.get("password") or "").strip()
if not name or len(name) > 32:
return jsonify({"ok": False, "message": "用户名必填且不超过 32 字符"}), 400
err = _check_password(pwd, body.get("password2"))
err = security.username_problem(name) or security.password_problem(
pwd, body.get("password2"), name)
if err:
return jsonify({"ok": False, "message": err}), 400
exist = conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()
if exist:
if db.user_by_name(conn, name):
return jsonify({"ok": False, "message": "用户名已存在"}), 400
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,created_at)"
" VALUES(?,?,?,?,?)",
(name, hash_password(pwd), (body.get("display_name") or name).strip()[:64],
1 if str(body.get("is_admin", "1")) in ("1", "true", "on") else 0,
db.now_str()))
db.audit(conn, "user_create", (current_user() or {}).get("username"), "新建用户 " + name,
request.remote_addr)
return jsonify({"ok": True, "message": "已创建用户 " + name})
# 默认建**普通账号**:多用户系统里「默认给管理员」是最常见的越权起点
adm = 1 if str(body.get("is_admin", "0")) in ("1", "true", "on") else 0
cur = conn.execute(
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,created_at)"
" VALUES(?,?,?,?,?, 'active', ?)",
(name, security.hash_password(pwd),
(body.get("display_name") or name).strip()[:64],
(body.get("email") or "").strip()[:128] or None, adm, db.now_str()))
db.audit(conn, "user_create", me["username"],
"新建用户 %s(%s)" % (name, "管理员" if adm else "普通"), request.remote_addr, me["id"])
return jsonify({"ok": True, "message": "已创建用户 %s" % name, "id": cur.lastrowid})
@bp.post("/users/<int:uid>")
@admin_required
def api_user_update(uid):
from ..security import hash_password
conn = db.get_db()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
me = current_user()
row = db.user_by_id(conn, uid)
if row is None:
return jsonify({"ok": False, "message": "用户不存在"}), 404
body = request.get_json(silent=True) or {}
me = current_user()
body = _json_body()
changed = []
if "display_name" in body:
conn.execute("UPDATE users SET display_name=? WHERE id=?",
((body.get("display_name") or "").strip()[:64], uid))
changed.append("显示名")
if "email" in body:
email = (body.get("email") or "").strip()[:config.PROFILE_EMAIL_MAX]
if email and ("@" not in email or " " in email):
return jsonify({"ok": False, "message": "邮箱格式不正确"}), 400
conn.execute("UPDATE users SET email=? WHERE id=?", (email or None, uid))
changed.append("邮箱")
if "is_admin" in body:
v = 1 if str(body.get("is_admin")) in ("1", "true", "on") else 0
if uid == me["id"] and not v:
return jsonify({"ok": False, "message": "不能取消自己的管理员身份"}), 400
if not v and row["is_admin"]:
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
" AND id<>?", (uid,)).fetchone()[0]
if left == 0:
return jsonify({"ok": False,
"message": "至少要保留一个启用状态的管理员"}), 400
conn.execute("UPDATE users SET is_admin=? WHERE id=?", (v, uid))
changed.append("管理员")
if "status" in body:
v = "active" if str(body.get("status")) in ("active", "1", "true", "on") else "disabled"
if uid == me["id"] and v != "active":
return jsonify({"ok": False, "message": "不能停用自己的账号"}), 400
if v != "active":
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
" AND id<>?", (uid,)).fetchone()[0]
if row["is_admin"] and left == 0:
return jsonify({"ok": False,
"message": "至少要保留一个启用状态的管理员"}), 400
conn.execute("UPDATE users SET status=? WHERE id=?", (v, uid))
changed.append("状态→" + ("启用" if v == "active" else "停用"))
pwd = (body.get("password") or "").strip()
if pwd:
err = _check_password(pwd, body.get("password2"))
err = security.password_problem(pwd, body.get("password2"), row["username"])
if err:
return jsonify({"ok": False, "message": err}), 400
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pwd), uid))
conn.execute("UPDATE users SET password_hash=? WHERE id=?",
(security.hash_password(pwd), uid))
changed.append("密码")
if not changed:
return jsonify({"ok": False, "message": "没有要修改的内容"}), 400
db.audit(conn, "user_update", me["username"],
"修改用户 %s:%s" % (row["username"], "、".join(changed)), request.remote_addr)
"修改用户 %s:%s" % (row["username"], "、".join(changed)),
request.remote_addr, me["id"])
return jsonify({"ok": True, "message": "已更新:" + "、".join(changed)})
@@ -432,15 +583,41 @@ def api_user_update(uid):
def api_user_delete(uid):
conn = db.get_db()
me = current_user()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
row = db.user_by_id(conn, uid)
if row is None:
return jsonify({"ok": False, "message": "用户不存在"}), 404
if uid == me["id"]:
return jsonify({"ok": False, "message": "不能删除当前登录的自己"}), 400
n = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if n <= 1:
if conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] <= 1:
return jsonify({"ok": False, "message": "至少要保留一个账号"}), 400
if row["is_admin"]:
left = conn.execute("SELECT COUNT(*) FROM users WHERE is_admin=1 AND status='active'"
" AND id<>?", (uid,)).fetchone()[0]
if left == 0:
return jsonify({"ok": False, "message": "至少要保留一个启用状态的管理员"}), 400
keep = str(_json_body().get("keep_data", "")).strip() in ("1", "true", "on", "yes")
if not keep:
# 默认连同数据一起删 —— 留下孤儿数据既占空间,也会在重新注册
# 同名用户时被新用户看到(历史遗留 user_id 复用风险)
conn.execute("DELETE FROM usage_records WHERE user_id=?", (uid,))
conn.execute("DELETE FROM settings WHERE user_id=?", (uid,))
conn.execute("DELETE FROM collect_runs WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
db.audit(conn, "user_delete", me["username"], "删除用户 " + row["username"],
request.remote_addr)
db.audit(conn, "user_delete", me["username"],
"删除用户 %s(%s)" % (row["username"], "保留其数据" if keep else "连同数据一并删除"),
request.remote_addr, me["id"])
return jsonify({"ok": True, "message": "已删除 " + row["username"]})
@bp.post("/captcha")
@login_required
def api_captcha_note():
"""给前端一个「验证码怎么工作」的自述,便于排障时自检。"""
conn = db.get_db()
return jsonify({
"policy": db.get_setting(conn, "captcha_policy", "always"),
"length": db.get_int(conn, "captcha_length", 4),
"ttl_seconds": 300,
"image_url": "/captcha.png",
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。",
})
+20
查看文件
@@ -115,6 +115,9 @@ code {
.topbar .me { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }
.topbar .who { color: var(--sub); font-size: 12.5px; }
.topbar .who b { color: var(--text); font-weight: 600; }
/* 用户名同时是「个人中心」入口,所以是 <a>:去掉下划线并给悬浮反馈 */
.topbar .who { display: flex; align-items: center; gap: 6px; text-decoration: none; }
.topbar .who:hover, .topbar .who:hover b { color: var(--cyan); }
/* ---------------- 布局 ---------------- */
.wrap { max-width: 1480px; margin: 0 auto; padding: 20px 22px 60px; }
@@ -379,6 +382,23 @@ select option { background: #111a2e; color: var(--text); }
margin: 16px 0 0; padding-top: 14px; border-top: 1px solid var(--line);
color: var(--dim); font-size: 11.5px; line-height: 1.7;
}
/* 注册页字段比登录页多,卡片给宽一点(沿用 .login 的纵向节奏) */
.login.wide { max-width: 470px; }
/* 字段说明(如「3~32 位,字母或数字开头」)跟在 label 文字后面 */
.login label em.unit { font-style: normal; margin-left: 6px; font-size: 11px; color: var(--dim); }
/* 验证码:输入框与图片并排一行(.caprow 是**新组件独有**前缀,
刻意不叫 .bar —— 页面里 .bar 是筛选条,带着 backdrop-filter 与 margin,
撞名会导致整块文字被虚化且被顶高) */
.caprow { display: flex; align-items: center; gap: 10px; }
.caprow input { flex: 1 1 auto; min-width: 0; letter-spacing: 2px; }
/* 图片按 PNG 原始高度显示(150x56,scale=5),不缩放才最清晰 */
.capimg {
flex: 0 0 auto; height: 56px; width: auto; display: block;
border: 1px solid var(--line); border-radius: var(--r-ctl);
background: var(--panel-dim); cursor: pointer; transition: .15s;
}
.capimg:hover { border-color: var(--cyan); }
/* ---------------- 错误页 ---------------- */
.errpage { text-align: center; padding: 66px 24px; }
@@ -190,7 +190,9 @@
<div class="navback">
<a class="abtn" href="/">← 返回后台</a>
<a class="abtn" href="/records">数据明细</a>
<a class="abtn" href="/logs">日志管理</a>
<!-- 日志管理仅管理员可达:角色由 /api/manifest 的 role 字段带回,
非管理员时在 boot() 里隐藏,避免给出会 403 的死链 -->
<a class="abtn" id="abtnLogs" href="/logs" hidden>日志管理</a>
</div>
</div>
<div class="meta" data-page-node-id="4jmZuGBGC8JQmVAE2IOJ7H">
@@ -1272,6 +1274,9 @@
$("#err").style.display = "none";
const mf = src.manifest || {};
$("#mUpdate").textContent = mf.generated || "-";
// 管理员入口按角色显隐(大屏是静态页,拿不到 Jinja 上下文)
const logsBtn = $("#abtnLogs");
if (logsBtn) logsBtn.hidden = (mf.role !== "admin");
initDays();
bind();
renderScope();
+7 -1
查看文件
@@ -61,6 +61,12 @@
var out = {};
Array.prototype.forEach.call(form.elements, function (el) {
if (!el.name || el.type === "submit" || el.name === "_csrf") return;
// 只读控件必须跳过:disabled 的 input 仍然出现在 form.elements 里,
// 若被一起提交,服务端会因为「越权修改只读项」把**整单**拒掉 ——
// 表现成「改了 A 却提示 A 不能改」,很难归因。
// 注意:fieldset 被 disable 时子元素自身的 disabled 仍是 false,
// 所以还要往上找祖先 fieldset。
if (el.disabled || (el.closest && el.closest("fieldset[disabled]"))) return;
if (el.type === "checkbox") { out[el.name] = el.checked ? "1" : "0"; return; }
if (el.type === "radio") { if (el.checked) out[el.name] = el.value; return; }
out[el.name] = el.value;
@@ -72,7 +78,7 @@
function hintOf(j) {
if (!j) return "";
if (j.error === "cookie_expired") return "\n请到「配置管理」更新 Cookie 与 User-Agent(两者须取自同一次浏览器请求)。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试,或到「日志管理」查看进度。";
if (j.error === "busy") return "\n已有采集在进行,可稍后重试;「任务管理」里能看到本次运行的状态。";
if (j.errors && j.errors.length) return "\n" + j.errors.join("\n");
return "";
}
+18 -5
查看文件
@@ -4,6 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}{{ project_title }}{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
@@ -11,7 +12,11 @@
<body>
{% set nav = active|default('') %}
{% set on_dash = request.path.startswith('/dashboard') %}
{% if current_user() %}
{# 变量名刻意叫 cur 而不是 me:模板里的 {% set %} 会覆盖子模板传入的同名变量,
而 current_user() 只含 id/username/display_name/is_admin —— 曾因此让
个人中心把 me.created_at 渲染成空(子模板的 me 是完整的用户行)。 #}
{% set cur = current_user() %}
{% if cur %}
<header class="topbar">
<div class="brand">
<span class="dot"></span>
@@ -24,13 +29,21 @@
<a href="{{ url_for('views.records') }}" class="{{ 'on' if nav=='records' }}">数据明细</a>
<a href="{{ url_for('views.tasks') }}" class="{{ 'on' if nav=='tasks' }}">任务管理</a>
<a href="{{ url_for('views.config_page') }}" class="{{ 'on' if nav=='config' }}">配置管理</a>
{# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见;
服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #}
{% if cur.is_admin %}
<a href="{{ url_for('views.logs') }}" class="{{ 'on' if nav=='logs' }}">日志管理</a>
{% if current_user().is_admin %}
{% endif %}
{% if cur.is_admin %}
<a href="{{ url_for('views.users_page') }}" class="{{ 'on' if nav=='users' }}">用户管理</a>
{% endif %}
</nav>
<div class="me">
<span class="who"><b>{{ current_user().display_name }}</b>{% if current_user().is_admin %} <span class="tag accent">管理员</span>{% endif %}</span>
<a class="who" href="{{ url_for('views.profile_page') }}" title="个人中心">
<b>{{ cur.display_name }}</b>
{% if cur.is_admin %}<span class="tag accent">管理员</span>
{% else %}<span class="tag mute">普通账号</span>{% endif %}
</a>
{# 退出用 POST + CSRF:GET 型退出会被 <img src="/logout"> 这类请求静默触发 #}
<form method="post" action="{{ url_for('views.logout_post') }}" style="margin:0">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
@@ -52,8 +65,8 @@
</main>
<footer class="foot">
<b>{{ project_title }}</b> · {{ project_name }} · 采集 / 存储 / 呈现三合一 · 数据正本 <code>data/usage.sqlite</code><br>
采集在 Web 进程内按配置时刻执行,无需外部计划任务
<b>{{ project_title }}</b> · {{ project_name }} · 多用户 · 调度与采集参数由管理员统一设定<br>
每个账号只使用并只见得到<b>自己的</b> Cookie 与用量数据;数据正本 <code>data/usage.sqlite</code>
</footer>
<script>
+118 -12
查看文件
@@ -5,24 +5,42 @@
<div class="pagehead">
<div>
<h1>配置管理</h1>
<p class="lead">凭证、采集参数、维护动作都在这里;所有配置存在数据库,改完立即生效</p>
{% if is_admin %}
<p class="lead">凭证、采集参数、接口地址与注册策略都在这里;改完立即生效</p>
{% else %}
<p class="lead">这里维护<b>你自己账号</b>的云端凭证;采集参数与调度由管理员统一设定,只读展示</p>
{% endif %}
</div>
</div>
{% if s.cookie_broken %}
<div class="flash error" style="margin-bottom:16px">
已保存的 Cookie <b>无法解密</b>(通常是 <code>data/instance.json</code> 里的
<code>cookie_key</code> 被更换或文件丢失)。请重新粘贴一次;在此之前该账号的采集会失败。
</div>
{% endif %}
<section class="card">
<div class="cardhead">
<h2>云端凭证</h2>
<h2>我的云端凭证</h2>
<span class="tag {{ 'ok' if s.cookie_hint else 'bad' }}">{{ '已配置' if s.cookie_hint else '未配置' }}</span>
</div>
<p class="hint">
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}(页面与接口都不回传明文){% endif %}
{% if s.cookie_hint %}当前 Cookie:{{ s.cookie_hint }}
{% if s.cookie_at %}({{ s.cookie_at }} 更新){% endif %}
—— 页面与接口都不回传明文,数据库里也是密文。{% endif %}
<br>获取方式:Chrome 打开 <code>https://www.workbuddy.cn/profile/plans-usage</code> → F12 → Network →
任选一个 <code>billing</code> 请求 → 复制 Request Headers 里的 <code>cookie</code> 与 <code>user-agent</code>
(<b>两者必须取自同一次请求</b>),粘贴到下面。
<br><b>请粘贴你自己账号的 Cookie</b>:采集只使用本人凭证,各账号的数据互不可见。
{% if not is_admin %}
<br><b>这一块是你唯一可以修改的配置</b> —— 其余参数与调度时刻由管理员统一设定。
{% endif %}
</p>
<form id="formCred">
<label class="col">Cookie
<textarea name="cookie" rows="4" placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie" spellcheck="false"></textarea>
<textarea name="cookie" rows="4" autocomplete="off" spellcheck="false"
placeholder="留空表示不修改;填 - 表示清空已保存的 Cookie"></textarea>
</label>
<label class="col">User-Agent
<textarea name="user_agent" rows="2" spellcheck="false">{{ s.user_agent }}</textarea>
@@ -33,11 +51,21 @@
<div class="grid2">
<section class="card">
<h2>采集参数</h2>
<div class="cardhead">
<h2>采集参数</h2>
{% if is_admin %}
<span class="tag accent">实例级 · 对所有账号生效</span>
{% else %}
<span class="tag mute">只读 · 仅管理员可改</span>
{% endif %}
</div>
{% if is_admin %}
<form id="formCollect">
<label class="row"><span>接口基址</span><input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
<label class="row"><span>接口路径</span><input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
{% set b = num_settings %}
<label class="row"><span>接口基址</span>
<input name="api_base" value="{{ s.api_base }}" spellcheck="false"></label>
<label class="row"><span>接口路径</span>
<input name="api_path" value="{{ s.api_path }}" spellcheck="false"></label>
<label class="row"><span>分页大小</span>
<input name="page_size" type="number" min="{{ b.page_size[0] }}" max="{{ b.page_size[1] }}" value="{{ s.page_size }}">
<em class="unit">{{ b.page_size[0] }}~{{ b.page_size[1] }} 条/页</em></label>
@@ -64,8 +92,23 @@
</label>
<p class="hint">Cookie 就是账号凭证,关掉证书校验等于把它暴露给中间人,非必要不要关。</p>
<button class="btn primary" type="submit">保存采集参数</button>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。</p>
<p class="hint">整日校验会按天重新拉云端 total 与本地比对,发现缺记录自动补入;设为 0 表示关闭(日常够用)。
这些是<b>实例级</b>参数,改一次对本机所有账号生效。</p>
</form>
{% else %}
<p class="hint">这些参数影响采集行为与云端压力,属于整机策略,普通账号只读。</p>
<table class="kv">
<tr><th>接口基址</th><td class="mono">{{ s.api_base }}</td></tr>
<tr><th>接口路径</th><td class="mono">{{ s.api_path }}</td></tr>
<tr><th>分页大小</th><td>{{ s.page_size }} 条/页</td></tr>
<tr><th>断点回退</th><td>{{ s.rewind_minutes }} 分钟</td></tr>
<tr><th>时间漂移容差</th><td>{{ s.drift_tolerance_minutes }} 分钟</td></tr>
<tr><th>Prompt 截断</th><td>{{ s.max_prompt }} 字符{% if s.max_prompt == '0' %}(不截断){% endif %}</td></tr>
<tr><th>整日校验天数</th><td>{{ s.verify_days }} 天</td></tr>
<tr><th>请求超时</th><td>{{ s.timeout }} 秒</td></tr>
<tr><th>校验 TLS 证书</th><td>{% if s.ssl_verify != '0' %}<span class="tag ok">校验</span>{% else %}<span class="tag bad">不校验</span>{% endif %}</td></tr>
</table>
{% endif %}
</section>
<section class="card">
@@ -75,7 +118,8 @@
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
<button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 6 位。修改成功后当前会话仍有效,不必重新登录。</p>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
</form>
<hr class="sect-divider">
@@ -84,18 +128,78 @@
<button class="btn" type="button" data-maint="fill-prompt"
title="把云端仍保留、但本地为空的 User Prompt 补回来">补全缺失 Prompt</button>
<button class="btn" type="button" data-maint="export-csv"
title="导出与官网 xlsx 同构的全量 CSV 到 data/exports/">导出全量 CSV</button>
title="导出与官网 xlsx 同构的 CSV 到 data/exports/">导出我的 CSV</button>
{% if is_admin %}
<button class="btn" type="button" data-maint="vacuum"
title="checkpoint + VACUUM,回收删除后的空闲页">整理数据库</button>
title="checkpoint + VACUUM,回收删除后的空闲页(整库操作,仅管理员)">整理数据库</button>
{% endif %}
</div>
<p class="hint">补全 Prompt 需要联网并逐天重拉云端;导出与整理只动本地数据。</p>
<div class="btnrow" style="margin-top:14px">
<a class="btn ghost" href="{{ url_for('views.records_export') }}">按当前明细页筛选导出</a>
{% if current_user().is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
<a class="btn ghost" href="{{ url_for('views.profile_page') }}">个人中心</a>
{% if is_admin %}<a class="btn ghost" href="{{ url_for('views.users_page') }}">用户管理</a>{% endif %}
</div>
</section>
</div>
{% if not is_admin %}
<section class="card">
<div class="cardhead">
<h2>为什么采集参数是只读的</h2>
<span class="tag mute">普通账号</span>
</div>
<p class="hint" style="margin:0">
你只能维护<b>本人账号的凭证</b>(Cookie 与 User-Agent)—— 采集始终只用你自己的这份凭证,
拿回来的数据也只会存在你自己的作用域里,别人看不到、你也看不到别人的。
分页大小、超时、接口地址、TLS 校验、调度时刻这些属于<b>整机策略</b>:它们既关系到云端
压力,也关系到所有人的采集是否安全,因此统一由管理员设定。
<br>如果你确实需要调整,把上面任意一项截图给管理员即可。
</p>
</section>
{% endif %}
{% if is_admin %}
<section class="card">
<div class="cardhead">
<h2>实例级设置</h2>
<span class="tag accent">仅管理员可改,对所有账号生效</span>
</div>
<form id="formGlobal">
{% set b = num_settings %}
<label class="row"><span>开放自助注册</span>
<select name="allow_register">
<option value="1" {{ 'selected' if s.allow_register != '0' }}>允许任何人注册</option>
<option value="0" {{ 'selected' if s.allow_register == '0' }}>关闭注册(只能由管理员建号)</option>
</select>
</label>
<label class="row"><span>同 IP 每日注册上限</span>
<input name="register_max_per_ip" type="number"
min="{{ b.register_max_per_ip[0] }}" max="{{ b.register_max_per_ip[1] }}"
value="{{ s.register_max_per_ip }}">
<em class="unit">{{ b.register_max_per_ip[0] }}~{{ b.register_max_per_ip[1] }} 个/天</em></label>
<label class="row"><span>验证码策略</span>
<select name="captcha_policy">
<option value="always" {{ 'selected' if s.captcha_policy == 'always' }}>始终要求(推荐)</option>
<option value="adaptive" {{ 'selected' if s.captcha_policy == 'adaptive' }}>仅连续失败后要求(对日常更友好)</option>
<option value="off" {{ 'selected' if s.captcha_policy == 'off' }}>关闭(不推荐)</option>
</select>
</label>
<label class="row"><span>验证码位数</span>
<input name="captcha_length" type="number"
min="{{ b.captcha_length[0] }}" max="{{ b.captcha_length[1] }}"
value="{{ s.captcha_length }}">
<em class="unit">4~6 位</em></label>
<button class="btn primary" type="submit">保存实例设置</button>
<p class="hint">
验证码的答案只存在服务端 <code>captchas</code> 表里(下发到浏览器的只是一个随机 id),
一次性使用、5 分钟过期 —— 所以答案不会随会话 Cookie 泄漏出去。
关闭验证码会显著放大被撞库与批量注册的风险,只有在前面已经有可信网关时才考虑。
</p>
</form>
</section>
{% endif %}
{% endblock %}
{% block scripts %}
@@ -104,6 +208,8 @@
WBU.bindForm('#formCred', '/api/settings');
WBU.bindForm('#formCollect', '/api/settings');
WBU.bindForm('#formPwd', '/api/password', {validate: d => d.new === d.new2 ? null : '两次输入的新密码不一致'});
var fg = document.querySelector('#formGlobal');
if (fg) WBU.bindForm('#formGlobal', '/api/settings');
WBU.bindMaint('[data-maint]');
</script>
{% endblock %}
+16 -1
查看文件
@@ -15,9 +15,24 @@
<label>密码
<input name="password" type="password" autocomplete="current-password" required>
</label>
{% if need_captcha %}
<label>验证码
<span class="caprow">
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
required placeholder="不区分大小写">
{# 点击换一张:URL 带时间戳,避免浏览器复用已被消费的旧图 #}
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
src="{{ url_for('views.captcha_png', purpose='login') }}&t={{ range(1000000)|random }}"
onclick="this.src='{{ url_for('views.captcha_png', purpose='login') }}&t=' + Date.now();">
</span>
</label>
{% endif %}
<button class="btn primary" type="submit">登 录</button>
{% if allow_register %}
<p class="foot-note">还没有账号?<a href="{{ url_for('views.register') }}">自助注册</a></p>
{% endif %}
<p class="foot-note">
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即到「配置管理」修改密码。<br>
首次部署默认账号 <code>admin</code> / <code>admin123</code>,登录后请立即修改密码。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟;登录状态保持 {{ session_hours }} 小时。
</p>
</form>
+4 -3
查看文件
@@ -5,7 +5,7 @@
<div class="pagehead">
<div>
<h1>日志管理</h1>
<p class="lead">采集逐次日志、应用运行日志与操作审计</p>
<p class="lead">本机全实例的采集日志、应用运行日志与操作审计(仅管理员可见)</p>
</div>
<div class="actions">
<button class="btn" id="btnTail" type="button">刷新应用日志</button>
@@ -49,12 +49,13 @@
</div>
<div class="tablewrap">
<table class="tbl">
<thead><tr><th>#</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
<thead><tr><th>#</th><th>账号</th><th>开始</th><th>触发</th><th>状态</th><th class="num">耗时</th>
<th class="num">新增</th><th class="num">重复</th><th class="num">冲突</th><th>结论</th><th></th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.id }}</td>
<td>{{ r.uname }}</td>
<td class="mono nowrap">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="nowrap">
@@ -70,7 +71,7 @@
<td><a href="{{ url_for('views.logs', run=r.id, status=status or none) }}">详情</a></td>
</tr>
{% else %}
<tr><td colspan="10" class="empty">暂无采集日志</td></tr>
<tr><td colspan="11" class="empty">暂无采集日志</td></tr>
{% endfor %}
</tbody>
</table>
+6 -3
查看文件
@@ -58,8 +58,10 @@
<tr><th>Cookie</th><td>{% if mf.health.cookie %}<span class="tag ok">已配置</span>{% else %}<span class="tag bad">未配置</span> <a href="{{ url_for('views.config_page') }}">去配置</a>{% endif %}</td></tr>
<tr><th>服务器时间</th><td class="mono">{{ sch.now }}</td></tr>
</table>
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。</p>
<p class="hint">调度在 Web 进程内运行,不再需要计划任务或外部自动化。
{% if current_user().is_admin %}所有时刻与开关都在
<a href="{{ url_for('views.tasks') }}">任务管理</a>里改。{% else %}时刻由管理员统一设定,
你可以在<a href="{{ url_for('views.tasks') }}">任务管理</a>里查看,也可以随时手动采集本人数据。{% endif %}</p>
</section>
<section class="card">
@@ -98,7 +100,8 @@
<tbody>
{% for r in runs %}
<tr>
<td><a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a></td>
{# 运行详情在「日志管理」里,而那页仅管理员可达 —— 普通用户不要给死链 #}
<td>{% if current_user().is_admin %}<a href="{{ url_for('views.logs', run=r.id) }}">{{ r.id }}</a>{% else %}{{ r.id }}{% endif %}</td>
<td><span class="tag {{ 'info' if r.trigger=='schedule' else ('accent' if r.trigger=='startup' else 'mute') }}">{{ r.trigger }}</span></td>
<td class="mono">{{ r.started_at[5:] if r.started_at else '—' }}</td>
<td class="num">{{ ((r.duration_ms or 0) / 1000) | round(1) }}s</td>
+103
查看文件
@@ -0,0 +1,103 @@
{% extends "base.html" %}
{% block title %}个人中心 · {{ project_title }}{% endblock %}
{% block body %}
<div class="pagehead">
<div>
<h1>个人中心</h1>
<p class="lead">账号 <b>{{ me.username }}</b> · 注册于 {{ (me.created_at or '')[:16] }} ·
最近登录 {{ (me.last_login_at or '未登录')[:19] }}</p>
</div>
<div class="actions">
<a class="btn" href="{{ url_for('views.config_page') }}">管理我的凭证</a>
</div>
</div>
<div class="kpis">
<div class="kpi" style="--c:var(--cyan)">
<span>我的记录</span><b>{{ '{:,}'.format(my.n or 0) }}</b>
<i>{{ my.d0 or '—' }} ~ {{ my.d1 or '—' }}</i>
</div>
<div class="kpi" style="--c:var(--violet)">
<span>我的积分</span><b>{{ '%.2f'|format(my.c or 0) }}</b>
<i>仅统计归属本账号的数据</i>
</div>
<div class="kpi" style="--c:var(--blue)">
<span>采集次数</span><b>{{ '{:,}'.format(runs) }}</b>
<i>{% if sch.last %}最近 {{ sch.last.started_at[5:16] if sch.last.started_at else '—' }}{% else %}尚无采集{% endif %}</i>
</div>
<div class="kpi" style="--c:{{ 'var(--green)' if cred.set and not cred.broken else 'var(--red)' }}">
<span>我的 Cookie</span>
<b>{% if cred.broken %}无法解密{% elif cred.set %}已配置{% else %}未配置{% endif %}</b>
<i>{% if cred.set and not cred.broken %}{{ cred.chars }} 字符,结尾 …{{ cred.tail }}
{%- elif cred.broken %}实例密钥被更换,请重新粘贴
{%- else %}采集需要本人凭证{% endif %}</i>
</div>
</div>
<div class="grid2">
<section class="card">
<h2>修改资料</h2>
<form id="formProfile">
<label class="row"><span>用户名</span>
<input value="{{ me.username }}" disabled spellcheck="false">
<em class="unit">登录名不可改</em></label>
<label class="row"><span>显示名</span>
<input name="display_name" maxlength="64" value="{{ me.display_name or '' }}" spellcheck="false"></label>
<label class="row"><span>邮箱</span>
<input name="email" type="email" maxlength="128" value="{{ me.email or '' }}" spellcheck="false"></label>
<button class="btn primary" type="submit">保存资料</button>
</form>
</section>
<section class="card">
<h2>修改登录密码</h2>
<form id="formPwd">
<label class="col">原密码<input name="old" type="password" autocomplete="current-password"></label>
<label class="col">新密码<input name="new" type="password" autocomplete="new-password"></label>
<label class="col">确认新密码<input name="new2" type="password" autocomplete="new-password"></label>
<button class="btn primary" type="submit">修改密码</button>
<p class="hint">至少 {{ pwd_min }} 位,且需包含大写字母、小写字母、数字、符号中的至少两类。
修改成功后当前会话仍有效,不必重新登录。</p>
</form>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>我的采集凭证</h2>
<span class="tag {{ 'ok' if cred.set and not cred.broken else ('bad' if not cred.set else 'warn') }}">
{{ '正常' if cred.set and not cred.broken else ('未配置' if not cred.set else '需要重配') }}</span>
</div>
<table class="kv">
<tr><th>状态</th><td>
{% if cred.broken %}<span class="tag bad">已保存但无法解密</span>,请到「配置管理」重新粘贴
{% elif cred.set %}<span class="tag ok">已保存(密文入库)</span>
{% else %}<span class="tag bad">未配置</span>{% endif %}
</td></tr>
<tr><th>字符数 / 尾部</th><td class="mono">{{ cred.chars or 0 }} / {{ ('…' + cred.tail) if cred.tail else '—' }}</td></tr>
<tr><th>最后更新</th><td class="mono">{{ cred.at or '—' }}</td></tr>
<tr><th>调度</th><td>
{% if sch.enabled %}{{ sch.times | join(' · ') or '未设置时刻' }}{% else %}<span class="tag bad">已停用</span>{% endif %}
{% if sch.next_run %}· 下次 <span class="mono">{{ sch.next_run }}</span>{% endif %}
</td></tr>
</table>
<p class="hint">
凭证以密文形式存在数据库里(主密钥在 <code>data/instance.json</code>),
页面与接口**任何时候都不回传明文**,只显示长度与尾部 4 位。要更换请到
<a href="{{ url_for('views.config_page') }}">配置管理</a>粘贴新的 Cookie 与 User-Agent
(两者必须取自同一次浏览器请求)。
</p>
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
<script>
WBU.bindForm('#formProfile', '/api/profile');
WBU.bindForm('#formPwd', '/api/password', {
validate: function (d) { return d.new === d.new2 ? null : '两次输入的新密码不一致'; }
});
</script>
{% endblock %}
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}注册 · {{ project_title }}{% endblock %}
{% block body %}
<div class="loginwrap">
<form class="card login wide" method="post" action="{{ url_for('views.register') }}">
<div class="logo">W</div>
<h1>注册 {{ project_title }}</h1>
<p class="hint">注册后请粘贴<strong>你自己账号</strong>的 Cookie —— 采集只使用本人的凭证,各账号数据互相隔离。</p>
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<label>用户名 <em class="unit">3~32 位,字母或数字开头</em>
<input name="username" value="{{ username or '' }}" autocomplete="username"
autofocus required maxlength="32" spellcheck="false">
</label>
<label>显示名 <em class="unit">留空则与用户名相同</em>
<input name="display_name" value="{{ display_name or '' }}" maxlength="64" autocomplete="nickname">
</label>
<label>邮箱 <em class="unit">选填,便于日后找回</em>
<input name="email" type="email" value="{{ email or '' }}" maxlength="128" autocomplete="email">
</label>
<label>密码 <em class="unit">至少 {{ pwd_min }} 位,需含两类以上字符</em>
<input name="password" type="password" autocomplete="new-password" required>
</label>
<label>确认密码
<input name="password2" type="password" autocomplete="new-password" required>
</label>
{% if need_captcha %}
<label>验证码
<span class="caprow">
<input name="captcha" maxlength="6" autocomplete="off" spellcheck="false"
required placeholder="不区分大小写">
<img class="capimg" alt="图形验证码" title="看不清?点一下换一张"
src="{{ url_for('views.captcha_png', purpose='register') }}&t={{ range(1000000)|random }}"
onclick="this.src='{{ url_for('views.captcha_png', purpose='register') }}&t=' + Date.now();">
</span>
</label>
{% endif %}
<button class="btn primary" type="submit">注 册</button>
<p class="foot-note">已有账号?<a href="{{ url_for('views.login') }}">返回登录</a></p>
<p class="foot-note">
注册受来源限额与图形验证码双重保护;同一来源每天可注册的账号数由管理员设定。<br>
连续输错 {{ max_fails }} 次将锁定 {{ lock_minutes }} 分钟。
</p>
</form>
</div>
{% endblock %}
+26 -5
查看文件
@@ -5,7 +5,7 @@
<div class="pagehead">
<div>
<h1>任务管理</h1>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里也能手动触发与按区间回填</p>
<p class="lead">调度在 Web 进程内执行,采集互斥由文件锁保证;这里能看到本人账号的运行历史,也能手动触发与按区间回填</p>
</div>
<div class="actions">
<button class="btn primary" id="btnCollect" type="button">立即采集一次</button>
@@ -15,7 +15,11 @@
<div class="grid2">
<section class="card">
<h2>采集调度</h2>
<div class="cardhead">
<h2>采集调度</h2>
{% if not can_edit %}<span class="tag mute">只读 · 仅管理员可改</span>{% endif %}
</div>
{% if can_edit %}
<form id="formTask">
<label class="row"><span>启用调度</span>
<select name="schedule_enabled">
@@ -26,8 +30,8 @@
<label class="row"><span>每日时刻</span>
<input name="schedule_times" value="{{ s_times }}" placeholder="09:00,17:00" spellcheck="false">
</label>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。保存后立即生效,
并会清空当天已执行的槽位标记以便新时刻接管。</p>
<p class="hint">本地时区,逗号分隔,支持 <code>HH:MM</code>(也可只写 <code>9</code>)。
保存后立即生效;已经过去且不再存在的时刻会被清掉,新的时刻当天就会接管。</p>
<label class="row"><span>启动补跑</span>
<select name="catch_up">
<option value="1" {{ 'selected' if sch.catch_up }}>开启(错过的时刻在宽限期内补跑)</option>
@@ -39,7 +43,21 @@
<em class="unit">小时(超过就不补,避免开机狂刷)</em>
</label>
<button class="btn primary" type="submit">保存调度配置</button>
<p class="hint">这套时刻是<b>实例级</b>的:本机上所有账号在同一个时刻各自采集自己的数据。</p>
</form>
{% else %}
<table class="kv">
<tr><th>启用调度</th><td>{% if sch.enabled %}<span class="tag ok">已启用</span>{% else %}<span class="tag bad">已停用</span>{% endif %}</td></tr>
<tr><th>每日时刻</th><td>{{ sch.times | join(' · ') if sch.times else '—' }}</td></tr>
<tr><th>启动补跑</th><td>{% if sch.catch_up %}<span class="tag">开启</span>(宽限 {{ s_grace }} 小时){% else %}关闭{% endif %}</td></tr>
<tr><th>下次执行</th><td class="mono">{{ sch.next_run or '—' }}</td></tr>
</table>
<p class="hint">
调度时刻与采集参数由<b>管理员统一设定</b>,普通账号只读。
你随时可以在右上角点「立即采集一次」拉取本人账号的最新用量,
也可以在下面按区间补采自己的历史数据 —— 采集始终只用<b>你自己的</b> Cookie。
</p>
{% endif %}
</section>
<section class="card">
@@ -96,7 +114,10 @@
<td class="num">{{ r.dup }}</td>
<td class="num">{% if r.conflicts %}<span class="tag warn">{{ r.conflicts }}</span>{% else %}0{% endif %}</td>
<td>{{ r.message or '—' }}</td>
<td><a href="{{ url_for('views.logs', run=r.id) }}">日志</a></td>
<td>
{% if can_edit %}<a href="{{ url_for('views.logs', run=r.id) }}">日志</a>
{% else %}<span class="muted">—</span>{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="12" class="empty">暂无运行记录</td></tr>
+53 -21
查看文件
@@ -5,10 +5,12 @@
<div class="pagehead">
<div>
<h1>用户管理</h1>
<p class="lead">门户在局域网可访问,因此必须靠账号隔离;这里维护账号、管理员身份与密码</p>
<p class="lead">维护账号、状态与管理员身份。单价数据<b>按账号隔离</b>——管理员也看不到别人的用量与凭证</p>
</div>
<div class="actions">
<span class="tag accent">仅管理员可见</span>
<span class="tag {{ 'ok' if allow_register else 'mute' }}">
自助注册:{{ '已开放' if allow_register else '已关闭' }}</span>
</div>
</div>
@@ -17,21 +19,25 @@
<h2>新建账号</h2>
<form id="formNewUser">
<label class="row"><span>用户名</span>
<input name="username" maxlength="32" placeholder="登录名(≤32 字符)" autocomplete="off" spellcheck="false"></label>
<input name="username" maxlength="32" placeholder="3~32 位,字母或数字开头"
autocomplete="off" spellcheck="false"></label>
<label class="row"><span>显示名</span>
<input name="display_name" maxlength="64" placeholder="留空则与用户名相同"></label>
<label class="row"><span>邮箱</span>
<input name="email" type="email" maxlength="128" placeholder="选填"></label>
<label class="row"><span>密码</span>
<input name="password" type="password" autocomplete="new-password"></label>
<label class="row"><span>确认密码</span>
<input name="password2" type="password" autocomplete="new-password"></label>
<label class="row"><span>权限</span>
<select name="is_admin">
<option value="1">管理员(可管理用户)</option>
<option value="0">普通账号(只读数据与日志)</option>
<option value="0">普通账号(只管自己的凭证与数据)</option>
<option value="1">管理员(可管理用户与实例设置)</option>
</select>
</label>
<button class="btn primary" type="submit">创建账号</button>
<p class="hint">密码至少 6 位、最多 128 位。普通账号不能用本页,也调不动用户管理接口。</p>
<p class="hint">密码至少 8 位且需含两类以上字符。新账号默认是<b>普通账号</b>;
管理员身份请显式选择。无论哪种身份,都需要各自配置自己的 Cookie 才能采集。</p>
</form>
</section>
@@ -42,29 +48,39 @@
</div>
<div class="tablewrap">
<table class="tbl" id="userTable">
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限</th>
<th>最后登录</th><th class="num">次数</th><th>操作</th></tr></thead>
<thead><tr><th class="num">ID</th><th>用户名</th><th>显示名</th><th>权限 / 状态</th>
<th class="num">我的数据</th><th>最后登录</th><th>操作</th></tr></thead>
<tbody>
{% for u in users %}
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}">
<tr data-uid="{{ u.id }}" data-name="{{ u.username }}"
data-status="{{ u.status or 'active' }}">
<td class="num muted">{{ u.id }}</td>
<td class="nowrap"><b>{{ u.username }}</b>
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}</td>
{% if u.id == me.id %}<span class="tag accent">当前</span>{% endif %}
{% if u.email %}<br><span class="muted sm">{{ u.email }}</span>{% endif %}</td>
<td><input class="inp inp-sm" name="display_name" maxlength="64"
value="{{ u.display_name or '' }}" spellcheck="false"></td>
<td>
{# 不能取消自己的管理员身份,所以本人的下拉直接禁用(服务端也会再拦一次) #}
<td class="nowrap">
{# 不能取消自己的管理员身份,也不能停用自己(服务端也会再拦一次) #}
<select class="inp inp-sm" name="is_admin" {{ 'disabled' if u.id == me.id }}>
<option value="1" {{ 'selected' if u.is_admin }}>管理员</option>
<option value="0" {{ 'selected' if not u.is_admin }}>普通</option>
</select>
<select class="inp inp-sm" name="status" {{ 'disabled' if u.id == me.id }}>
<option value="active" {{ 'selected' if (u.status or 'active') == 'active' }}>启用</option>
<option value="disabled" {{ 'selected' if u.status == 'disabled' }}>停用</option>
</select>
</td>
<td class="mono sm nowrap">{{ u.last_login_at or '—' }}</td>
<td class="num">{{ u.login_count }}</td>
<td class="num nowrap">{{ '{:,}'.format(u.recs or 0) }} 条
<br><span class="muted sm">{{ '%.2f'|format(u.credits or 0) }} 分</span></td>
<td class="mono sm nowrap">{{ (u.last_login_at or '—')[:16] }}
{% if u.last_login_ip %}<br><span class="muted">{{ u.last_login_ip }}</span>{% endif %}</td>
<td class="nowrap">
<button class="btn sm" type="button" data-act="save">保存</button>
<button class="btn sm ghost" type="button" data-act="pwd">改密</button>
{% if u.id != me.id %}
<button class="btn sm ghost" type="button" data-act="toggle">
{{ '停用' if (u.status or 'active') == 'active' else '启用' }}</button>
<button class="btn sm danger" type="button" data-act="del">删除</button>
{% endif %}
</td>
@@ -75,14 +91,19 @@
</tbody>
</table>
</div>
<p class="hint">「改密」会依次询问新密码与确认;管理员不能取消自己的管理员身份,任何人也不能删除自己。</p>
<p class="hint">
「停用」会让该账号<b>立刻</b>失效(每个请求都会校验状态,不必等会话过期),
其数据与 Cookie 都保留;「删除」是<b>不可逆</b>的,会连同该账号的用量数据与 Cookie
一起删除(接口层另有保留数据的开关,供脚本调用时指定)。
管理员不能取消自己的管理员身份、不能停用或删除自己,也不能删掉最后一个启用的管理员。
</p>
</section>
</div>
<section class="card">
<div class="cardhead">
<h2>用户操作审计</h2>
<span class="hint">最近 20 条</span>
<h2>账号操作审计</h2>
<span class="hint">最近 20 条(含注册与登录失败)</span>
</div>
<div class="tablewrap scroll-y">
<table class="tbl">
@@ -139,20 +160,31 @@
if (act === 'save') {
var fields = {};
fields.display_name = row.querySelector('[name=display_name]').value;
var sel = row.querySelector('[name=is_admin]');
if (sel && !sel.disabled) fields.is_admin = sel.value;
var adm = row.querySelector('[name=is_admin]');
if (adm && !adm.disabled) fields.is_admin = adm.value;
var st = row.querySelector('[name=status]');
if (st && !st.disabled) fields.status = st.value;
done(WBU.post('/api/users/' + uid, fields));
} else if (act === 'pwd') {
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 6 位)');
var p1 = window.prompt('为用户「' + name + '」设置新密码(至少 8 位,需含两类字符)');
if (p1 === null) { btn.disabled = false; btn.textContent = old; return; }
var p2 = window.prompt('再输入一次新密码以确认');
if (p2 === null) { btn.disabled = false; btn.textContent = old; return; }
if (p1 !== p2) { WBU.say('两次输入的密码不一致', 'warn'); btn.disabled = false; btn.textContent = old; return; }
done(WBU.post('/api/users/' + uid, { password: p1, password2: p2 }));
} else if (act === 'del') {
if (!window.confirm('确定删除用户「' + name + '」?该操作不可撤销(其历史审计记录会保留)。')) {
} else if (act === 'toggle') {
var cur = row.dataset.status === 'active';
var next = cur ? 'disabled' : 'active';
if (!window.confirm(cur ? ('停用「' + name + '」?其数据与 Cookie 会保留,但无法登录。')
: ('启用「' + name + '」?'))) {
btn.disabled = false; btn.textContent = old; return;
}
done(WBU.post('/api/users/' + uid, { status: next }));
} else if (act === 'del') {
var keep = window.confirm('确定删除用户「' + name + '」?\n\n'
+ '点「确定」= 连同其用量数据与 Cookie 一起删除(推荐)\n'
+ '点「取消」= 取消本次删除');
if (!keep) { btn.disabled = false; btn.textContent = old; return; }
done(WBU.post('/api/users/' + uid + '/delete', {}));
} else {
btn.disabled = false; btn.textContent = old;
+292 -78
查看文件
@@ -5,12 +5,26 @@
"""页面路由(Jinja 模板)。
分工:
/ 概览(KPI + 入口)
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
/tasks 任务管理:调度开关/时刻、手动触发、运行历史
/config 配置管理:Cookie / UA / 采集参数 / 改密码
/logs 日志管理:采集逐次明细 + 应用日志尾部
/records 数据明细:分页、筛选、搜索、导出
/ 概览(KPI + 入口)
/dashboard ECharts 交互大屏(独立静态页,登录后可达,数据走 /api/bundle)
/records 数据明细:分页、筛选、搜索、导出
/tasks 任务管理:运行历史、手动采集、补采(**调度配置仅管理员可改**)
/config 配置管理:本人的 Cookie / UA(**其余参数仅管理员可改**)
/logs 日志管理(**仅管理员**)
/profile 个人中心:资料、密码、凭证状态
/users 用户管理(仅管理员)
/register 自助注册(受 allow_register 开关约束)
/captcha.png 图形验证码
**多用户约定(两条)**
1. **数据作用域**:所有数据类页面都只取 `current_user()["id"]` 那份数据;
管理员在「用户管理」里能看到账号列表,但**看不到别人的用量与凭证**。
2. **写权限**:普通用户只能写 `config.USER_EDITABLE_KEYS`(本人的 cookie /
user_agent),其余配置(调度时刻、采集参数、接口地址、注册策略)都归
管理员。页面上的 disabled / 隐藏只是「不给误导性按钮」,真正的闸门在
`@admin_required` 与 `config.writable_by()`,两端共用一个判断。
"""
import csv
import io
@@ -18,12 +32,11 @@ import os
import sqlite3
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template,
request, send_from_directory, url_for)
request, send_from_directory, session, url_for)
from .. import collect, config, db, query, scheduler
from ..security import (admin_required, clear_fail, current_user, is_locked, lock_left,
login_ok, login_required, login_session, logout_session,
note_fail, safe_next)
from .. import collect, config, db, query, scheduler, security
from ..security import (admin_required, current_user, is_admin, login_required,
safe_next)
bp = Blueprint("views", __name__)
@@ -32,42 +45,182 @@ def _ip():
return request.headers.get("X-Forwarded-For", request.remote_addr or "").split(",")[0].strip()
def _uid():
"""当前账号 id。调用方必须已过 @login_required。"""
u = current_user()
return u["id"] if u else 0
def _shift(days):
from datetime import datetime, timedelta
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
# ---------------- 验证码 ----------------
@bp.get("/captcha.png")
def captcha_png():
"""生成一张验证码。答案只写进 captchas 表,会话里只记 id。"""
purpose = (request.args.get("purpose") or "login").strip().lower()
if purpose not in ("login", "register"):
purpose = "login"
if not security.captcha_fetch_allowed(_ip()):
return "验证码请求过于频繁,请稍后再试", 429
try:
png = security.issue_captcha(db.get_db(), purpose)
except sqlite3.Error:
return "验证码服务暂不可用", 503
resp = current_app.response_class(png, mimetype="image/png")
# 必须禁缓存:否则浏览器复用旧图,而服务端那张已经被消费掉了,
# 表现为「图没变但怎么输都错」。
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
return resp
# ---------------- 登录 ----------------
def _login_ctx(**kw):
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。"""
def _login_ctx(conn=None, **kw):
"""登录页共用的上下文:锁定阈值/会话时长都从配置读,避免模板里写死数字。
`need_captcha` 与 `allow_register` 也在这里补齐 —— 登录页与注册页
必须对「要不要验证码」保持一致,否则会出现「页面没给输入框、
服务端却在校验」的死循环。
"""
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
kw.setdefault("session_hours", config.SESSION_HOURS)
kw.setdefault("pwd_min", config.PASSWORD_MIN)
if conn is not None:
kw.setdefault("need_captcha", security.captcha_required(conn, _ip(), kw.get("username") or ""))
kw.setdefault("allow_register", security.register_allowed(conn))
return kw
@bp.route("/login", methods=["GET", "POST"])
def login():
nxt = request.values.get("next") or ""
conn = db.get_db()
if request.method == "POST":
ip = _ip()
if is_locked(ip):
n = lock_left(ip)
flash("登录失败次数过多,请 %d 秒后再试" % n, "error")
return render_template("login.html", **_login_ctx(next_url=nxt)), 429
username = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
conn = db.get_db()
user = login_ok(conn, username, pwd)
left = security.auth_locked(ip, username)
if left:
security.audit_login_fail(conn, username, "已锁定,剩余 %d 秒" % left, ip)
flash("登录失败次数过多,请 %d 秒后再试" % left, "error")
return render_template("login.html",
**_login_ctx(conn, next_url=nxt, username=username,
need_captcha=True)), 429
# 验证码**先于**口令校验:否则攻击者可以拿「密码对不对」当信号,
# 在解验证码之前就把字典跑完。
need_cap = security.captcha_required(conn, ip, username)
if need_cap and not security.consume_captcha(conn, "login", request.form.get("captcha")):
n = security.note_auth_fail(ip, username)
security.audit_login_fail(conn, username, "验证码错误(第 %d 次)" % n, ip)
flash("验证码不正确或已过期,请重新输入", "error")
return render_template("login.html",
**_login_ctx(conn, username=username, next_url=nxt,
need_captcha=True)), 400
user, err = security.login_ok(conn, username, pwd)
if user is None:
n = note_fail(ip)
db.audit(conn, "login_failed", username, "第 %d 次失败" % n, ip)
flash("用户名或密码不正确(剩余尝试 %d 次)" % max(0, config.MAX_LOGIN_FAILS - n), "error")
n = security.note_auth_fail(ip, username)
security.audit_login_fail(conn, username, err + "(第 %d 次)" % n, ip)
flash("%s(剩余尝试 %d 次)" % (err, max(0, config.MAX_LOGIN_FAILS - n)), "error")
# 必须把 next 显式回填:失败后 request.args 为空,
# 若模板从 request.args 取值会导致跳转目标丢失(历史 bug)。
return render_template("login.html", **_login_ctx(username=username, next_url=nxt)), 401
clear_fail(ip)
login_session(user)
db.audit(conn, "login", username, "登录成功", ip)
return redirect(safe_next(nxt, url_for("views.overview")))
return render_template("login.html",
**_login_ctx(conn, username=username, next_url=nxt,
need_captcha=True)), 401
security.clear_auth_fail(ip, username)
security.login_session(user)
conn.execute("UPDATE users SET last_login_ip=? WHERE id=?", (ip, user["id"]))
db.audit(conn, "login", username, "登录成功", ip, user["id"])
target = safe_next(nxt, "")
if not target:
# 新注册 / 还没配凭证 → 直接带到配置页,少一步摸索
cred = db.secret_state(conn, "cookie", user["id"])
target = url_for("views.config_page") if not cred["set"] else url_for("views.overview")
return redirect(target)
if current_user():
return redirect(url_for("views.overview"))
return render_template("login.html", **_login_ctx(next_url=nxt))
return render_template("login.html", **_login_ctx(conn, next_url=nxt))
# ---------------- 注册 ----------------
def _register_ctx(**kw):
kw.setdefault("max_fails", config.MAX_LOGIN_FAILS)
kw.setdefault("lock_minutes", config.LOGIN_LOCK_MINUTES)
kw.setdefault("pwd_min", config.PASSWORD_MIN)
return kw
@bp.route("/register", methods=["GET", "POST"])
def register():
conn = db.get_db()
if not security.register_allowed(conn):
return render_template("error.html", code=403,
message="管理员已关闭自助注册,请联系管理员开通账号"), 403
if current_user():
return redirect(url_for("views.overview"))
if request.method == "POST":
ip = _ip()
username = (request.form.get("username") or "").strip()
display = (request.form.get("display_name") or "").strip()[:64]
email = (request.form.get("email") or "").strip()[:128]
pwd = request.form.get("password") or ""
pwd2 = request.form.get("password2") or ""
ctx = _register_ctx(username=username, display_name=display, email=email,
need_captcha=True)
left = security.auth_locked(ip, username)
if left:
flash("操作过于频繁,请 %d 秒后再试" % left, "error")
return render_template("register.html", **ctx), 429
# 注册一律要验证码:这是唯一能让陌生人写库的入口
if not security.consume_captcha(conn, "register", request.form.get("captcha")):
security.note_auth_fail(ip, username)
db.audit(conn, "register_rejected", username or "-", "验证码错误", ip)
flash("验证码不正确或已过期,请重新输入", "error")
return render_template("register.html", **ctx), 400
ok, n, limit = security.register_quota(conn, ip)
if not ok:
db.audit(conn, "register_rejected", username or "-",
"同 IP 当日注册数已达上限 %d" % limit, ip)
flash("同一来源每天最多注册 %d 个账号,请明天再试或联系管理员" % limit, "error")
return render_template("register.html", **ctx), 429
err = security.username_problem(username) or security.password_problem(pwd, pwd2, username)
if err:
security.note_auth_fail(ip, username)
db.audit(conn, "register_rejected", username or "-", err, ip)
flash(err, "error")
return render_template("register.html", **ctx), 400
if db.user_by_name(conn, username):
# 用户名唯一性本来就暴露(注册时要查重),这里如实告知
flash("用户名已被占用,请换一个", "error")
return render_template("register.html", **ctx), 400
cur = conn.execute(
"INSERT INTO users(username,password_hash,display_name,email,is_admin,status,"
" created_at,register_ip) VALUES(?,?,?,?,0,'active',?,?)",
(username, security.hash_password(pwd), display or username, email or None,
db.now_str(), ip))
uid = cur.lastrowid
db.audit(conn, "register", username, "自助注册成功(账号 #%d)" % uid, ip, uid)
# 注册即登录:少一次输密码,也顺手把会话建立起来
row = db.user_by_id(conn, uid)
security.login_session(row)
security.clear_auth_fail(ip, username)
flash("注册成功。请粘贴你自己账号的 Cookie —— 采集只使用本人的凭证。", "ok")
return redirect(url_for("views.config_page"))
return render_template("register.html", **_register_ctx(need_captcha=True))
@bp.post("/logout")
@@ -76,8 +229,8 @@ def logout_post():
"""退出登录改为 POST + CSRF:GET 型退出会被 <img src> 这类请求静默触发。"""
u = current_user()
if u:
db.audit(db.get_db(), "logout", u["username"], "", _ip())
logout_session()
db.audit(db.get_db(), "logout", u["username"], "", _ip(), u["id"])
security.logout_session()
flash("已退出登录", "ok")
return redirect(url_for("views.login"))
@@ -96,29 +249,25 @@ def logout():
@login_required
def overview():
conn = db.get_db()
mf = query.manifest(conn)
t = query.totals(conn)
uid = _uid()
mf = query.manifest(conn, uid)
t = query.totals(conn, uid)
today = db.now_str()[:10]
st = query.summary(conn, today, today)
d30 = query.summary(conn, _shift(-29), today)
st = query.summary(conn, uid, today, today)
d30 = query.summary(conn, uid, _shift(-29), today)
# 昨日对比:昨日整日 vs 今日(残日),让「今天偏少」有参照
y = _shift(-1)
yest = query.summary(conn, y, y)
dims = query.dims(conn)
yest = query.summary(conn, uid, y, y)
dims = query.dims(conn, uid)
# 注意:这里的 SQL 必须把模板用到的列都选出来(模板渲染 r.fetched,
# 少选一列并不会报错,只会静默渲染成空白 —— 历史 bug)。
runs = conn.execute(
"SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,conflicts,message"
" FROM collect_runs ORDER BY id DESC LIMIT 8").fetchall()
" FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 8", (uid,)).fetchall()
return render_template("overview.html", mf=mf, totals=t, today_stat=st, stat30=d30,
yesterday=yest, yday=y,
models=dims["model"][:8], clients=dims["client"],
runs=runs, sch=_sch_info(conn), active="overview")
def _shift(days):
from datetime import datetime, timedelta
return (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d")
runs=runs, sch=_sch_info(conn, uid), active="overview")
# ---------------- 大屏(独立 ECharts 页)----------------
@@ -133,16 +282,17 @@ def dashboard():
# ---------------- 任务管理 ----------------
def _sch_info(conn):
def _sch_info(conn, uid):
sch = scheduler.get_scheduler()
nxt = scheduler.next_run_at(conn)
last = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT 1").fetchone()
nxt = scheduler.next_run_at(conn, uid)
last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1",
(uid,)).fetchone()
return {
"running": sch.running,
"enabled": db.get_bool(conn, "schedule_enabled", True),
"times": scheduler.slots(conn),
"enabled": db.get_bool(conn, "schedule_enabled", True, uid),
"times": scheduler.slots(conn, uid),
"next_run": nxt.strftime("%Y-%m-%d %H:%M:%S") if nxt else None,
"catch_up": db.get_bool(conn, "catch_up", True),
"catch_up": db.get_bool(conn, "catch_up", True, uid),
"interval": sch.interval,
"last": dict(last) if last else None,
"lock": os.path.exists(collect.LOCK_PATH),
@@ -154,16 +304,19 @@ def _sch_info(conn):
@login_required
def tasks():
conn = db.get_db()
uid = _uid()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 20
total = conn.execute("SELECT COUNT(*) FROM collect_runs").fetchone()[0]
runs = conn.execute("SELECT * FROM collect_runs ORDER BY id DESC LIMIT ? OFFSET ?",
(size, (page - 1) * size)).fetchall()
s = db.get_settings(conn)
total = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
(uid,)).fetchone()[0]
runs = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC"
" LIMIT ? OFFSET ?", (uid, size, (page - 1) * size)).fetchall()
s = db.get_settings(conn, uid=uid)
pages = max(1, (total + size - 1) // size)
return render_template("tasks.html", runs=runs, sch=_sch_info(conn),
return render_template("tasks.html", runs=runs, sch=_sch_info(conn, uid),
s_times=s.get("schedule_times") or "",
s_grace=s.get("catch_up_grace_hours") or "12",
can_edit=is_admin(),
page=page, pages=pages, total=total,
page_window=_page_window(page, pages),
active="tasks")
@@ -191,73 +344,130 @@ def _page_window(page, pages, span=9):
@login_required
def config_page():
conn = db.get_db()
s = db.get_settings(conn)
uid = _uid()
s = db.get_settings(conn, uid=uid)
for k in [k for k in list(s) if config.is_internal_key(k)]:
s.pop(k, None)
cookie = (s.pop("cookie", "") or "")
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (len(cookie), cookie[-16:])) if cookie else ""
return render_template("config.html", s=s, sch=_sch_info(conn),
# 加密键在 get_settings 里已经被置空,这里补上「状态摘要」给页面显示
st = db.secret_state(conn, "cookie", uid)
s["cookie_hint"] = ("%d 字符,结尾 …%s" % (st["chars"], st["tail"])) if st["set"] else ""
s["cookie_broken"] = st["broken"]
s["cookie_at"] = st["at"]
return render_template("config.html", s=s, sch=_sch_info(conn, uid),
secret_keys=config.SECRET_KEYS,
num_settings=config.NUM_SETTINGS,
pwd_min=config.PASSWORD_MIN,
is_admin=is_admin(),
global_keys=config.GLOBAL_KEYS,
active="config")
# ---------------- 个人中心 ----------------
@bp.get("/profile")
@login_required
def profile_page():
conn = db.get_db()
u = current_user()
row = db.user_by_id(conn, u["id"])
cred = db.secret_state(conn, "cookie", u["id"])
my = conn.execute(
"SELECT COUNT(*) n, COALESCE(SUM(credits),0) c, MIN(day) d0, MAX(day) d1"
" FROM usage_records WHERE user_id=?", (u["id"],)).fetchone()
runs = conn.execute("SELECT COUNT(*) FROM collect_runs WHERE user_id=?",
(u["id"],)).fetchone()[0]
return render_template("profile.html", me=dict(row), cred=cred, my=dict(my),
runs=runs, sch=_sch_info(conn, u["id"]),
pwd_min=config.PASSWORD_MIN, active="profile")
# ---------------- 用户管理 ----------------
@bp.get("/users")
@admin_required
def users_page():
conn = db.get_db()
users = conn.execute(
"SELECT id,username,display_name,is_admin,created_at,last_login_at,login_count"
" FROM users ORDER BY id").fetchall()
"SELECT u.id,u.username,u.display_name,u.email,u.is_admin,u.status,u.created_at,"
" u.register_ip,u.last_login_at,u.last_login_ip,u.login_count,"
" (SELECT COUNT(*) FROM usage_records r WHERE r.user_id=u.id) recs,"
" (SELECT COALESCE(SUM(credits),0) FROM usage_records r WHERE r.user_id=u.id) credits"
" FROM users u ORDER BY u.id").fetchall()
audits = conn.execute("SELECT * FROM audit_log WHERE action LIKE 'user%'"
" OR action IN ('register','register_rejected','password','login_failed')"
" ORDER BY id DESC LIMIT 20").fetchall()
return render_template("users.html", users=users, audits=audits,
me=current_user(), active="users")
me=current_user(), allow_register=db.get_bool(
conn, "allow_register", True),
active="users")
# ---------------- 日志管理 ----------------
# ---------------- 日志管理(仅管理员) ----------------
@bp.get("/logs")
@login_required
@admin_required
def logs():
"""日志管理 —— **仅管理员**。
这一页同时呈现「全实例采集日志」「进程级应用日志」「全实例操作审计」,
都是实例运行信息(会带数据库路径、账号名、采集区间、来源 IP)。
普通账号不该读到这些:他们要看自己的采集历史走「任务管理」,
那页只查本人的数据。服务端用 @admin_required 兜底,导航里也会隐藏入口。
"""
conn = db.get_db()
run_id = request.args.get("run")
detail = None
if run_id and str(run_id).isdigit():
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?", (int(run_id),)).fetchone()
detail = conn.execute("SELECT * FROM collect_runs WHERE id=?",
(int(run_id),)).fetchone()
status = request.args.get("status") or ""
w, p = ("WHERE status = ?", [status]) if status in ("ok", "warn", "error", "running") else ("", [])
# 操作审计:按动作筛选 + 分页(原来只能看最近 40 条,等于不可查)
if status in ("ok", "warn", "error", "running"):
w, p = "WHERE status = ?", [status]
else:
status, w, p = "", "", []
act = request.args.get("act") or ""
aw, ap = ("WHERE action = ?", [act]) if act else ("", [])
ap = [act] if act else []
aw_sql = "WHERE action = ?" if act else ""
apage = _int_arg("apage", 1, 1, 10 ** 6)
asize = 20
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw, ap).fetchone()[0]
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw,
atotal = conn.execute("SELECT COUNT(*) FROM audit_log %s" % aw_sql, ap).fetchone()[0]
audits = conn.execute("SELECT * FROM audit_log %s ORDER BY id DESC LIMIT ? OFFSET ?" % aw_sql,
ap + [asize, (apage - 1) * asize]).fetchall()
# 注意传的是 sqlite3.Row 列表而不是纯字符串列表:模板要用 a[0]=动作、a[1]=次数,
# 若在这里就用推导式取 r[0],模板里的 a[0] 会变成「字符串的第一个字符」。
actions = conn.execute(
"SELECT action, COUNT(*) n FROM audit_log GROUP BY action ORDER BY n DESC, action").fetchall()
page = _int_arg("page", 1, 1, 10 ** 6)
size = 30
total = conn.execute("SELECT COUNT(*) FROM collect_runs %s" % w, p).fetchone()[0]
runs = conn.execute("SELECT id,trigger,status,started_at,duration_ms,fetched,added,dup,total,"
"conflicts,exit_code,message FROM collect_runs %s"
" ORDER BY id DESC LIMIT ? OFFSET ?" % w, p + [size, (page - 1) * size]).fetchall()
# 带上账号名:这是实例级视图,一行没有归属人根本没法读
runs = conn.execute("SELECT r.id,r.user_id,COALESCE(u.username,'—') AS uname,"
" r.trigger,r.status,r.started_at,r.duration_ms,r.fetched,r.added,"
" r.dup,r.total,r.conflicts,r.exit_code,r.message"
" FROM collect_runs r LEFT JOIN users u ON u.id=r.user_id %s"
" ORDER BY r.id DESC LIMIT ? OFFSET ?" % w,
p + [size, (page - 1) * size]).fetchall()
apages = max(1, (atotal + asize - 1) // asize)
return render_template("logs.html", runs=runs, detail=detail, audits=audits,
actions=actions, act=act, apage=apage, apages=apages, atotal=atotal,
apage_window=_page_window(apage, apages, span=7),
page=page, pages=max(1, (total + size - 1) // size), total=total,
page_window=_page_window(page, max(1, (total + size - 1) // size)),
status=status,
active="logs")
status=status, active="logs")
@bp.get("/logs/tail")
@login_required
def logs_tail():
"""应用日志尾部(进程级,所有账号看到的是同一份)。
只对管理员开放:日志里会打印数据库路径、账号名等运行信息,
没有理由让任意注册用户读到整个实例的运行轨迹。
"""
if not is_admin():
return jsonify({"ok": False, "error": "forbidden",
"message": "应用日志仅管理员可查看"}), 403
n = _int_arg("lines", 200, 10, 2000)
path = config.APP_LOG
if not os.path.exists(path):
@@ -289,6 +499,7 @@ def _day_args():
@login_required
def records():
conn = db.get_db()
uid = _uid()
frm, to = _day_args()
model = request.args.get("model") or None
client = request.args.get("client") or None
@@ -296,10 +507,10 @@ def records():
order = request.args.get("order") or "ts_desc"
page = _int_arg("page", 1, 1, 10 ** 6)
size = _int_arg("size", 50, 10, query.MAX_PAGE_SIZE)
data = query.records_page(conn, frm, to, model=model, client=client, q=q,
data = query.records_page(conn, uid, frm, to, model=model, client=client, q=q,
page=page, size=size, order=order)
# 只算一次 dims:query.dims() 内部有 3 条 GROUP BY,重复调用纯属浪费
d = query.dims(conn)
d = query.dims(conn, uid)
models = [r["name"] for r in d["model"]]
clients = [r["name"] for r in d["client"]]
# 注意:不要把含 "items" 键的 dict 直接交给模板——Jinja 的属性查找会先命中
@@ -321,6 +532,8 @@ def records_export():
数据用 query.iter_records 流式取,不把整个结果集读进内存。
"""
from flask import Response
u = current_user()
uid = u["id"]
frm, to = _day_args()
model = request.args.get("model") or None
client = request.args.get("client") or None
@@ -340,7 +553,7 @@ def records_export():
# 自己开一条连接,并在流结束时关掉。
own = db.connect()
try:
for r in query.iter_records(own, frm, to, model=model, client=client,
for r in query.iter_records(own, uid, frm, to, model=model, client=client,
q=q, order=order):
buf.seek(0)
buf.truncate(0)
@@ -350,7 +563,8 @@ def records_export():
finally:
own.close()
name = "usage_%s_%s.csv" % (frm or "all", to or db.now_str()[:10])
# 文件名带账号名:多人导出到同一目录时不会互相覆盖
name = "usage_%s_%s_%s.csv" % (u["username"], frm or "all", to or db.now_str()[:10])
resp = Response(gen(), mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="%s"' % name})
# 导出可能很慢,避免 nginx 之类的前置代理先缓冲整个响应体