diff --git a/.gitignore b/.gitignore index 4040dfb..443bca7 100644 --- a/.gitignore +++ b/.gitignore @@ -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」 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ec75be..f6ad7d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,14 +44,16 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler ## 三、改动前请先跑一遍验证 -项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层: +项目有一层层递进的验证,**代价从低到高**,改完至少跑到第 2 层; +**只要动过 `*.md`,第 1.5 层必须跑**: | # | 命令 | 覆盖什么 | 需要什么 | |---|---|---|---| | 1 | `python -m compileall -q workbuddy_portal manage.py tools` | 语法 | — | -| 2 | `python tools/smoke.py` | **165 项**离线断言:全页面只读渲染、模板残留检测、多用户隔离与凭证保密、注册与验证码、历史缺陷防回归、静态资源、CSS 类名对账 | 无(用 Flask test_client,不启服务) | -| 3 | `python tools/check_live.py --base http://127.0.0.1:8848` | **83 项**真实 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, @@ -79,7 +81,7 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler 6. **`.gitignore` 不支持行尾注释**——规则后跟 `# 注释` 会让整行失效。注释必须单独占一行, 改完用 `git check-ignore -v ` 逐条确认命中。 -### 多用户相关的四条(v1.2.0 起) +### 多用户与权限相关的六条(v1.2.0 起,v1.3.0 扩充) 7. **`uid` 必须是 `conn` 之后的第一个位置参数,且不给默认值。** 这是防越权的核心机制:漏传就直接 `TypeError`,而不是静默返回所有人的数据。 @@ -90,24 +92,39 @@ WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler 9. **验证码答案只能放服务端。** 不要图省事塞进 `session`——Flask 的 session 是 「签名 + base64」而非加密,客户端能直接解开读到答案。下发给浏览器的只有随机 `captcha_id`; 且校验时**先删后判**(一次性)。同理,验证码图不要用 SVG 渲染,那玩意是文本。 -10. **改主键的迁移必须「删索引 → 改名 → 建新表 → 回填 → 删旧表」。** +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` 之前。 ### 加密与验证码这两块(零依赖约束) -11. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」 +14. **`crypto.py` 与 `captcha.py` 只能用标准库。** 项目的硬约束是「只要 Flask / waitress / openpyxl」 —— 所以 ChaCha20、HMAC、PNG 编码、点阵字模都是手写的。想引 `cryptography` 或 `Pillow` 之前先想清楚:这会让「下载即跑」的卖点消失。 -12. **解密失败必须显式报错,不能「失败就返回原值」。** `crypto.decrypt()` 对非 `v1.` 前缀 +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)。 diff --git a/Dockerfile b/Dockerfile index 7004ed6..2384f43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 3f48a15..0d5882a 100644 --- a/README.md +++ b/README.md @@ -11,15 +11,16 @@ | 语言 / 框架 | Python 3.11+ · Flask 3 · Jinja2 · 纯标准库 `urllib` 采集 | | 存储 | SQLite(WAL),单文件正本 `data/usage.sqlite` | | 前端 | 服务端渲染后台 + 独立 ECharts 大屏(离线自带的 `echarts.min.js`) | -| 部署 | Docker Compose / 裸机 `waitress`;镜像可推 Gitea 容器注册表 | +| 部署 | **三条路径**:裸机 / Docker 自打包 / compose 拉云端镜像;镜像可推 Gitea 容器注册表 | | 鉴权 | **多用户**(各自的数据与凭证严格隔离)+ 全站登录 + CSRF + 角色(管理员 / 普通) | +| 权限 | 普通账号**只能维护本人的 Cookie / User-Agent**;调度频率、采集参数、日志、用户管理都归管理员 | | 凭证 | Cookie **ChaCha20 + HMAC 静态加密**入库,页面与接口只回掩码 | | 防攻击 | 登录 / 注册**图形验证码**(服务端出题 + 一次性)、失败限速、注册限额 | -| 版本 | v1.2.0 | +| 版本 | v1.3.0 | | **许可证** | **MIT**(第三方组件与再分发资源见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)) | **目录**:[核心特性](#核心特性) · [架构](#架构一图) · [快速开始](#快速开始) · [命令一览](#命令一览) · -[页面一览](#页面一览) · [接口一览](#接口一览) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可) +[页面一览](#页面一览) · [接口一览](#接口一览) · [目录结构](#目录结构) · [文档导航](#文档导航) · [安全须知](#安全须知) · [开源与许可](#开源与许可) --- @@ -27,11 +28,13 @@ | 能力 | 说明 | |---|---| -| **多用户隔离** | 每个账号只填**自己的** Cookie、收**自己的**数据、看**自己的**日志。`user_id` 是所有查询的第一个条件,且是**必填位置参数**(漏传直接 `TypeError`,不会静默返回全量) | +| **多用户隔离** | 每个账号只填**自己的** 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`,按当前筛选窗口实时聚合;左侧多取等长一段用于算环比,窗口不变不重复请求 | @@ -45,7 +48,7 @@ ``` ┌──────────────── workbuddy-portal(单进程)────────────────┐ 云端用量接口 │ │ - /billing/meter/ │ scheduler.py ──┐ 按账号逐个判断槽位 │ + /billing/meter/ │ scheduler.py ──┐ 按实例级时刻表遍历启用账号 │ get-user-request- │ (20s 轮询槽位) │ │ usage │ ▼ │ ▲ │ collect.py ─ 文件锁 collect.lock ─ 去重 upsert ─▶ SQLite │ @@ -59,8 +62,8 @@ │ (Jinja 后台) (JSON) │ └───────────────┬───────────────────────────┬──────────────┘ ▼ ▼ - / /records /tasks /config /logs /dashboard(ECharts 大屏) - /users /profile + 未登录:/login /register + / /records /tasks /config /dashboard(ECharts 大屏) + /profile [/logs /users:仅管理员] + 未登录:/login /register ``` 五层职责: @@ -81,53 +84,77 @@ ## 快速开始 -### 方式一: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://: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 -``` - -> 从 v1.1.0 升级上来**不需要手工介入**:`init` 会检测到旧表结构并自动迁移 -> (历史数据归到首个账号、明文 Cookie 就地加密),全程带审计留痕,可重复执行。 - -### 第一次使用必做三件事 +**管理员:** 1. **改密码**——局域网可访问,默认密码等于没锁门(「个人中心」或「配置管理 → 修改密码」)。 -2. **填 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `cookie_expired`。 - 获取方式见 [用户手册](docs/USER-GUIDE.md#三获取并填写-cookie)。 -3. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效。 +2. **确认调度时刻**——「任务管理」里把 `09:00,17:00` 改成你的习惯时刻,保存即生效 + (这是**实例级**的,对全站账号生效)。 +3. **填自己的 Cookie**——「配置管理 → 凭证」,否则采集只会记一条 `no_cookie`。 + +**普通账号(注册进来的默认身份):** + +1. **改密码**——「个人中心 → 修改登录密码」。 +2. **填自己的 Cookie**——这是你**唯一**需要动手的配置。 +3. 想立刻看数据?点「任务管理 → 立即采集一次」,不用等调度时刻。 ### 想给同事开账号? 登录页底部有「**自助注册**」入口(管理员可在「配置管理 → 实例级设置」关掉)。 注册同样要过验证码,且同一来源每天最多注册 3 个账号(可改)。 +**注册出来的都是普通账号**:只能维护自己的 Cookie、只看自己的数据,看不到日志与用户管理。 每个账号登录后填**自己的** Cookie——系统不会、也无法把某人的凭证给别人用。 > 只想内部开号、不开放注册?管理员在「用户管理」页直接新建即可; @@ -139,7 +166,7 @@ python manage.py serve # 启动,默认 0.0.0.0:8848 统一入口是 `manage.py`(Docker 里同样可用:`docker compose exec portal python manage.py stats`)。 -**多用户下所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定; +**所有涉及数据/凭证的子命令都作用于某一个账号**,用 `-u/--user <用户名>` 指定; 不指定则取「管理员优先、其次 id 最小」的那个(所以旧习惯的单账号用法仍然成立)。 唯独 `collect` 不带 `-u` 时会**逐个启用账号**跑一遍,与进程内调度线程的行为一致。 @@ -148,7 +175,7 @@ python manage.py serve # 启动,默认 0.0.0.0:8848 | `init` | 初始化 / 迁移数据库(幂等)。`--user` / `--password` 指定首个管理员 | | `serve` | 启动 Web。`--host` `--port` `--debug` `--no-scheduler` | | `collect [-u 账号]` | 执行一次增量采集后退出;**不带 `-u` 则所有启用账号各跑一次** | -| `migrate-csv [文件] [-u 账号]` | 从旧版 CSV 存档导入(默认自动探测旧项目路径),必须说明「算谁的」 | +| `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%) | @@ -163,20 +190,26 @@ python manage.py serve # 启动,默认 0.0.0.0:8848 | 脚本 | 层 | 说明 | |---|---|---| -| `tools/smoke.py` | 离线回归 | `test_client` 对真实库全页面只读渲染,**165 项断言**:历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、CSV 列、class↔CSS 对账、静态资源逐个 200。**不需要先起服务** | -| `tools/check_live.py` | 真实 HTTP | 对运行中的服务走真实链路(登录 → CSRF → 各页面 → 各 API → 导出 → 安全项 → **验证码与响应头**),**83 项断言**,基本只读 | -| `tools/shots.py` | 界面实检 | Playwright 登录后逐页截图并收集 console / pageerror,产物在 `data/shots/` | -| `tools/demo_data.py` | 示例数据 | 生成**完全合成**的示例库(两个账号,各有自己的数据与假 Cookie),文档截图基于它 | +| `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` 审计行,并**临时**建两个普通账号用于验证权限边界与注册链路 -> (无论成败都在 `finally` 里删掉),不动任何用量数据; +> `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(答案本身只存在于服务端)。 @@ -185,22 +218,23 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图 ## 页面一览 -| 路径 | 作用 | -|---|---| -| `/login` | **登录**(未登录时的落点):用户名 / 密码 / **图形验证码**,底部有自助注册入口 | -| `/register` | **自助注册**:用户名、显示名、邮箱、密码 + 验证码;注册成功直接登录并引导去填自己的 Cookie | -| `/` | **概览**:KPI(含今日 vs 昨日整日)、采集健康度、调度状态、模型 TOP、最近采集 | -| `/dashboard` | **ECharts 交互大屏**(独立静态页):日历热力图、趋势、维度分布、单笔 TOP,支持区间/维度/指标联动 | -| `/records` | **数据明细**:快捷区间、日期/模型/客户端/关键词筛选、排序、分页、展开 Prompt、导出 CSV | -| `/tasks` | **任务管理**:调度开关与时刻、启动补跑、按区间补采、运行历史 | -| `/config` | **配置管理**:自己的 Cookie / UA、采集参数、TLS 校验、维护动作;底部是实例级设置区(仅管理员可改) | -| `/logs` | **日志管理**:**只看得到自己账号的**逐次采集详情(含 `[warn]`/`[error]` 原文)、操作审计;应用日志尾部仅管理员 | -| `/profile` | **个人中心**:账号概况、我的凭证状态(密文入库)、改密码;点右上角用户名进入 | -| `/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`。 --- @@ -211,27 +245,25 @@ python tools/shots.py --base http://127.0.0.1:8849 --full # 逐页截图 **所有数据接口都只返回当前登录账号的数据** —— `user_id` 由会话决定,不接受客户端传入。 完整参数说明见 [docs/API.md](docs/API.md)。 -| 方法 | 路径 | 作用 | -|---|---|---| -| GET | `/api/manifest` | 存档总量、日期区间、存活日清单、数据源、健康状态(含 `cookieChars`/`cookieBroken`) | -| 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/` | 明细分页 / 单条详情(`` 也受 `user_id` 约束) | -| GET | `/api/runs` · `/api/runs/` | 采集运行历史 / 单次详情(含逐行日志) | -| GET | `/api/status` | 调度状态、下次执行、互斥锁、最近采集 | -| GET | `/api/audit` | 操作审计分页 + 可选动作清单(管理员看全站,普通账号看自己) | -| POST | `/api/collect` | 手动触发采集(可指定区间补采);未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken` | -| POST | `/api/maintenance/` | `fill-prompt` \| `export-csv` \| `vacuum` \| `recount`(`vacuum` 仅管理员) | -| GET/POST | `/api/settings` | 读 / 写配置。读只回**掩码** `cookie_hint`;非管理员写实例级键会被拒(`400` + `denied` 清单) | -| POST | `/api/profile` | 改自己的显示名 / 邮箱 | -| POST | `/api/password` | 修改自己的登录密码 | -| POST | `/api/captcha` | 验证码机制自述(策略、位数、TTL、图片地址),便于排障自检 | -| GET/POST | `/api/users` · `/api/users/` · `/api/users//delete` | 用户管理(仅管理员) | -| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | -| 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/` | 明细分页 / 单条详情(`` 也受 `user_id` 约束) | 登录 | +| GET | `/api/runs` · `/api/runs/` | 采集运行历史 / 单次详情(含逐行日志) | 登录 | +| 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/` | `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/` · `/api/users//delete` | 用户管理 | **仅管理员** | +| GET | `/captcha.png?purpose=login\|register` | **图形验证码图片**(唯一无需登录的接口;每次都是新题,带 `no-store`) | 公开 | +| GET | `/logs/tail` | 应用日志尾部 | **仅管理员** | +| GET | `/records/export` | 按筛选流式导出 CSV | 登录(只导自己) | --- @@ -240,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) @@ -248,15 +280,19 @@ 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 离线回归(165 项断言) -│ ├── check_live.py 真实 HTTP 验收(83 项断言) +│ ├── smoke.py 离线回归(215 项断言) +│ ├── check_live.py 真实 HTTP 验收(122 项断言,支持 --as 普通账号) │ ├── shots.py Playwright 逐页截图 + JS 报错收集 -│ └── demo_data.py 生成合成示例库(两个账号) +│ ├── demo_data.py 生成合成示例库(管理员 + 普通账号) +│ └── push-all.sh 一条命令推代码 + 推镜像到 Gitea └── workbuddy_portal/ ├── __init__.py create_app:配置 / 日志 / 蓝图 / 错误页 / 启动调度 - ├── config.py 路径、项目标识、默认值、写时校验、密钥管理 + ├── config.py 路径、项目标识、默认值、**配置作用域与写权限**、密钥管理 ├── db.py SQLite 连接、schema、按作用域读写配置、账号、审计 ├── schema.sql 表结构(多用户布局) ├── crypto.py 凭证静态加密(手写 ChaCha20 + HMAC-SHA256) @@ -264,7 +300,7 @@ workbuddy-portal/ ├── security.py 密码哈希、会话、CSRF、失败限速、验证码策略、角色、响应头 ├── client.py 云端接口(urllib)+ 编辑器凭证读取 ├── collect.py 增量采集 / 去重入库 / 互斥锁 / xlsx 导入 / CSV 导出 - ├── scheduler.py 进程内调度线程(按账号遍历 + 槽位去重 + 启动补跑) + ├── scheduler.py 进程内调度线程(实例级时刻表 + 槽位去重 + 启动补跑) ├── query.py SQL 聚合层(uid 必填) └── web/ ├── views.py 页面路由(含 /login /register /captcha.png /profile) @@ -278,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 失效、时区、性能、权限 | @@ -302,29 +344,39 @@ workbuddy-portal/ 局域网可访问 ⇒ 以下每一条都必要: -- **必须改默认密码**;给只读同事发普通账号(`is_admin=0`),不要共用管理员。 +- **必须改默认密码**;给同事发**普通账号**(注册出来的默认身份),不要共用管理员 + ——管理员是能停用别人账号的角色。 +- **权限只有两档,且规则只有一条**:`config.writable_by(key, is_admin)`。 + 普通账号能写的只有 `USER_EDITABLE_KEYS = {cookie, user_agent}`(且只限本人这份); + 调度频率、采集参数、注册策略、日志、用户管理全部关死。 + **页面上的置灰/隐藏只是「不给误导性按钮」,真正的闸门在 `@admin_required` 与 + `writable_by()` 这两个服务端判断上**——所以直接敲 URL 或构造请求也过不去。 - **数据按账号隔离**:`uid` 是所有查询的必填位置参数(漏传直接报错,不会静默返回全量); `/api/runs/`、`/api/records/` 这类按 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 型退出能被 `` 静默触发)。 + 前端 `formData()` 会跳过 `disabled` 控件(含祖先 `fieldset[disabled]`)—— + disabled 的 input 仍在 `form.elements` 里,一起提交会让服务端因「越权修改只读项」拒掉**整单**。 - **开放重定向防护**:登录跳转的 `next` 只接受站内相对路径,`//evil.com` 这类协议相对 URL 一律回落到 `/`。 - **登录限速**:按 **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`、`logs/`、`.env`(含明文密码)。 + `data/usage.sqlite`、`data/shots/`、`data/demo/`、`backups/`、`logs/`、`.env`(含明文密码)。 --- @@ -345,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) 走私有渠道。 @@ -359,11 +411,20 @@ Apache-2.0 许可)——之所以内置而不走 CDN,是为了让大屏在 Cookie 是 `deadbeef…` / `cafef00d…` 这类一眼可辨的假串, 审计 IP 取自 RFC 5737 的文档专用网段(`192.0.2.0/24`)。 生成方式是 `tools/demo_data.py`(会造 `admin` 与 `demo` 两个账号,各有自己的数据), -所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(11 张, -含注册页与个人中心),脚本会读示例库里的验证码答案自动过掉登录。 +所以任何人不需要真实账号就能复现整套文档。截图由 `tools/shots.py` 逐页重出(**13 张**, +含注册页、个人中心,以及**普通账号视角的只读「任务管理 / 配置管理」**), +脚本会读示例库里的验证码答案自动过掉登录。 -> 重出截图时请用**相对路径**起示例服务(`WB_DATA_DIR=data/demo`):用绝对路径会让启动日志 -> 印出 `C:\Users\<用户名>\…`,而那一行正好会出现在「日志管理」页的截图上。 +> ⚠️ **重出截图时务必用相对路径起示例服务**: +> ```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` 报「需要验证码但取不到答案」)。 ### 致谢 diff --git a/SECURITY.md b/SECURITY.md index 4c96d86..023374a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,9 +6,15 @@ | 版本 | 是否接受安全修复 | |---|---| -| `1.2.x`(当前) | ✅ | +| `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` / `cookie_key`、可复现的绕过步骤)。 @@ -37,9 +43,12 @@ | 项 | 做法 | 位置 | |---|---|---| | 全站鉴权 | 每个页面都有 `@login_required`,每个 `/api/*` 未登录返回 401 JSON | `security.login_required`、`web/views.py` | -| 角色 | 管理员 / 普通两档;`/users`、`/logs/tail`、`vacuum` 等仅管理员 | `security.admin_required` | +| 角色(两档) | 管理员 / 普通。**仅管理员**:`/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` | +| 自锁保护 | 管理员不能停用 / 降权 / 删除自己;也不能删掉最后一个启用的管理员 | `web/api.py` | | CSRF | 所有写请求必须带 `X-CSRF-Token`,页面注入 `window.WB_CSRF`,服务端统一拦截;退出登录也是 POST | `security.check_csrf` | | 会话签名 | Flask `secret_key` 由 `data/instance.json` 持有,首次启动随机生成 | `workbuddy_portal/config.py` | | 会话 cookie | `HttpOnly` + `SameSite=Lax` + `Path=/`;HTTPS 部署可设 `WB_COOKIE_SECURE=1` 打开 Secure | `workbuddy_portal/__init__.py` | @@ -54,10 +63,14 @@ |---|---|---| | 强隔离 | `uid` 是 `conn` 之后的**第一个位置参数且无默认值**;漏传直接 `TypeError`,不会退化成「返回全量」 | `query.py` / `collect.py` / `scheduler.py` | | 按 id 取单条也隔离 | `/api/records/`、`/api/runs/` 的 `WHERE` 都带 `user_id` | `web/api.py` | -| 配置作用域 | 三级回落 `个人 → 实例(user_id=0) → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 | `db.get_settings`、`config.GLOBAL_KEYS` | +| **配置作用域与写权限对齐** | 「键存在哪一级」与「谁能改」是一件事:`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` | -| 日志隔离 | 采集运行记录按账号下发;`/logs/tail`(应用日志文件)仅管理员 | `web/views.py` | +| 实例级也不存凭证 | `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)的保密 @@ -69,7 +82,7 @@ | 只回掩码 | 页面与 `/api/settings` 只给「N 字符,结尾 …xxxx」与 `broken` 标志,`secret_state()` 不返回明文 | `db.secret_state` | | 失败即报错 | `decrypt()` 校验失败**抛 `DecryptError`**,绝不「失败就返回原值」;非 `v1.` 前缀视为历史明文原样返回(下次写入自动升级) | `crypto.decrypt` | | 历史明文清理 | 启动迁移时把 settings 里残留的明文凭证就地加密,并写一条 `encrypt_secrets` 审计 | `db._encrypt_legacy_secrets` | -| TLS 校验 | 默认开启,**不提供「关掉校验」的快捷开关**(Cookie 不该裸奔) | `settings.ssl_verify` | +| TLS 校验 | 默认开启;`ssl_verify` 是**实例级且仅管理员可改** —— 能让普通账号关掉它,等于允许把所有人的 Cookie 发往不校验证书的地址 | `settings.ssl_verify`、`config.GLOBAL_KEYS` | ### 防自动化攻击 @@ -91,16 +104,22 @@ | 不索引 | 页面带 `noindex, nofollow` | `web/templates/base.html` | **绝不入库**:`data/instance.json`(含 `secret_key` 与 `cookie_key`)、`data/usage.sqlite`、 +`data/shots/`(截图里可能有真实账号信息)、`data/demo/`、`backups/`(库快照 = 凭证密文 + 密码哈希)、 `logs/*`、`.env`。 + `.gitignore` 已覆盖;改动忽略规则后请用 `git check-ignore -v ` 逐条复核。 注意 `.gitignore` **不支持行尾注释**(`path # 说明` 会让整行变成永不匹配的模式)。 +> `backups/` 同时也是一个**刻意不放在 `data/`** 的目录:`data/` 在 Docker 部署下是命名卷, +> `docker compose down -v` 会把备份和正本一起删掉 —— 那正好是最需要备份的时刻。 + ## 已知的**非**目标(部署方需自行处理) 本项目刻意不做下面这些,请按你的环境补齐: - **没有强制 HTTPS**:请由反向代理(nginx/Caddy)终止 TLS。纯 HTTP 部署时 **不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie(表现为反复被弹回登录页)。 + 注意:纯 HTTP 下流量在网内是明文的,同一局域网内的中间人可以看到会话 Cookie 与 Prompt 内容。 - **没有 CSRF 之外的重放防护 / 没有 WAF**:公网暴露前请置于反向代理的 rate limit 之后。 - **没有备份机制**:备份策略需要你自己定(见 `docs/DEPLOYMENT.md`)。 - **没有邮件/短信找回**:邮箱只是联系信息,不参与认证;密码忘掉由管理员重置。 @@ -108,6 +127,9 @@ - **Cookie 的获取方式由使用者负责**:手动从浏览器复制、**粘贴给自己的账号**。 它的权限等同于你的账号,请勿分享给他人;轮换后记得在「配置管理」页更新。 - **`cookie_key` 泄露 = 所有 Cookie 泄露**:`data/instance.json` 的权限应与数据库同级看待。 +- **管理员在运维层面是可信角色**:能登录部署机器的人可以看到数据库文件、应用日志, + 理论上也能改代码绕过界面限制。所以团队共用时请把「能登服务器」与「日常使用」分开 —— + 界面层的隔离保护的是**使用者之间**,不是「使用者 vs 服务器管理员」。 ## 部署前的最小检查清单 @@ -116,6 +138,10 @@ - [ ] `data/` 与 `logs/` 目录的权限只对服务账号可读写(内含 `instance.json` 的两个密钥) - [ ] 前面有反向代理并启用了 HTTPS;若是 HTTPS,已设 `WB_COOKIE_SECURE=1` - [ ] 确认 `data/instance.json` 没有被提交到任何仓库 +- [ ] `backups/` 也未被提交,且已确认 `.gitignore` 生效(`git check-ignore -v backups/x.sqlite`) - [ ] 已规划备份(SQLite 库是唯一正本);备份文件同样受 `cookie_key` 保护,需按机密对待 -- [ ] 升级到 1.2.0 后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 +- [ ] 新增的账号一律用**普通角色**;只有确实需要维护实例的人才给管理员 +- [ ] 升级后登录一次「配置管理」,确认 Cookie 状态为「已配置」而不是 「已保存但无法解密」 +- [ ] 升级到 1.3.0 后确认「任务管理」里调度时刻**对普通账号是只读的** + (用普通账号点一次「保存」应被拒并点名越权项) diff --git a/backups/README.md b/backups/README.md new file mode 100644 index 0000000..cb7ad8f --- /dev/null +++ b/backups/README.md @@ -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` 一个。 diff --git a/docs/API.md b/docs/API.md index 9a76395..c165ca3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -214,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` 操作审计分页。 @@ -255,9 +264,14 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。 "cookie_hint": "92 字符,…c0ffee", "cookie_broken": false, "user_agent": "Mozilla/5.0 (...)", - "_globalKeys": ["allow_register", "api_base", "api_path", - "captcha_length", "captcha_policy", "register_max_per_ip"], - "_canEditGlobal": true + "_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" } ``` @@ -267,8 +281,14 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。 | `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 | | `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 | | `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie | -| `_globalKeys` | 实例级键清单(所有账号共用一份,只有管理员能改) | +| `_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`(管理员) @@ -382,19 +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"]}` | +| 不可写的键(普通账号写实例级) | `400 {"ok": false, "error": "invalid", "errors": ["以下配置仅管理员可修改,本账号无法保存:api_base。普通账号可以维护的是本人凭证(Cookie / User-Agent)。"]}` | 要点: +- **写权限判断只有一处**:`config.writable_by(key, is_admin)`。 + 普通账号能写的**只有** `cookie` 与 `user_agent`(且只限本人这份); + 其余(云端接口、注册策略、调度、采集参数)全部仅管理员。 +- **越权写是「整单拒绝」而不是「部分生效」**:请求里只要含一个不可写的键, + 整个请求 `400`,并在 `errors` 里**点名**是哪些键。这样调用方不会误以为 + 「既然 `changed` 里没有它就说明写过了」。 - `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);写 `__clear__` 或 `-` 才是清空; - `cookie` 落库前会**自动加密**(`db.set_secret`),写进去的永远不是明文; -- **实例级键**(`_globalKeys`:`api_base` / `api_path` / `allow_register` / - `register_max_per_ip` / `captcha_policy` / `captcha_length`)非管理员**写不了** - —— 否则任意注册用户都能把大家的数据采集指向别的服务器; - 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**; -- 内部键(`slot:*`)被忽略; -- 改了 `schedule_times` / `schedule_enabled` 会清掉**自己**的槽位标记,新时刻立即生效; -- 每次拒绝都会写一条 `settings_rejected` 审计。 +- 内部键(`slot:*`)被忽略 —— 它们是调度簿记,不属于用户可配置项; +- 改 `schedule_times` 会清掉**已不存在时刻**对应的 `slot:*` 标记(所有账号一起清), + 新时刻立即生效。刻意**不做全清**:全清会让所有账号在宽限期内一起重采。 +- 每次拒绝都会写一条 `settings_rejected` 审计(管理员的「日志管理 → 操作审计」里能看到, + 这也是排查「谁的账号在试越权」的入口)。 ### POST `/api/password` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 689c138..5d541f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,8 +88,13 @@ audit_log(id, user_id, at, actor, action, detail, ip) ``` **`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示 -**实例级**(所有账号共用,例如 `api_base`、系统迁移审计);在 `usage_records` 里 -是「尚未归属」的兜底值,正常不会出现。 +**实例级**(所有账号共用,例如 `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)`、 @@ -298,7 +303,12 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt) ### 授权与数据隔离 `@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+ -`@admin_required`(403)两层。`/users`、`/api/users*`、`/logs/tail`、`vacuum` 要管理员。 +`@admin_required`(403)两层。**仅管理员**:`/users`、`/api/users*`、`/logs`、`/logs/tail`、`vacuum`。 + +两者之间还有一层「同一个页面、两种形态」:`/tasks` 与 `/config` 对普通账号**仍然可达**, +但渲染成**只读形态**(不给表单,换成只读表格 + 一句说明为什么只读), +写接口也会拒绝。页面形态与接口判断**共用 `config.writable_by()`**, +所以不存在「界面上没按钮、构造请求却能改」的空隙 —— 这是本次权限收敛刻意保证的性质。 **多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计: @@ -319,7 +329,7 @@ scheduler.slots(conn, uid=0) | 项 | 做法 | |---|---| | 单条读取也过滤 | `/api/records/`、`/api/runs/` 的 `WHERE` 都带 `user_id` | -| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 | +| 配置作用域 | 三级回落 `个人 → 实例 → 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` 普通账号只看自己 | @@ -421,9 +431,30 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认 └── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级 ``` -`GLOBAL_KEYS`(`api_base` / `api_path` / `allow_register` / `register_max_per_ip` / -`captcha_policy` / `captcha_length`)在读写两侧都被强制折算到 `user_id = 0`, -所以它们天然只有一份,非管理员改不了。 +`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**(非空), @@ -513,13 +544,15 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00, | 层 | 手段 | 抓什么 | |---|---|---| | 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 | -| 2 | `tools/smoke.py`(**165 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、静态资源 404、class↔CSS 对账 | -| 3 | `tools/check_live.py`(**83 项断言**,真实 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 层 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4b7aa98..6683c5a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,94 @@ --- +## [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 加密 · 开放注册与验证码** @@ -159,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-绑定挂载的坑容器打不开数据库)。 ### 安全 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 719fe0a..9c87f6a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,173 +1,147 @@ # 部署与运维指南 -> 面向**运维 / 部署者**。从零到跑起来,以及跑起来之后的备份、升级、排错。 +> 面向**部署者 / 运维**。三条并列的部署路径,选一条走到底;之后是备份、升级、排错。 +> +> 想了解界面怎么用,看 [用户使用指南](USER-GUIDE.md);想了解内部结构,看 [架构说明](ARCHITECTURE.md)。 **目录** -- [一、部署方式怎么选](#一部署方式怎么选) -- [二、Docker Compose 部署](#二docker-compose-部署) -- [三、裸机部署](#三裸机部署) -- [四、反向代理与 HTTPS](#四反向代理与-https) -- [五、把镜像推到 Gitea 注册表](#五把镜像推到-gitea-注册表) -- [六、备份与恢复](#六备份与恢复) -- [七、升级与回滚](#七升级与回滚) -- [八、日常巡检](#八日常巡检) -- [九、排错](#九排错) -- [十、配置项速查](#十配置项速查) +- [一、三条部署路径怎么选](#一三条部署路径怎么选) +- [二、部署前准备(三条路径共用)](#二部署前准备三条路径共用) +- [三、路径 A:裸机部署](#三路径-a裸机部署) +- [四、路径 B:Docker 自打包部署](#四路径-bdocker-自打包部署) +- [五、路径 C:docker-compose 拉云端镜像部署](#五路径-cdocker-compose-拉云端镜像部署) +- [六、反向代理与 HTTPS](#六反向代理与-https) +- [七、把代码与镜像推到 Gitea 注册表](#七把代码与镜像推到-gitea-注册表) +- [八、备份与恢复](#八备份与恢复) +- [九、升级与回滚](#九升级与回滚) +- [十、日常巡检](#十日常巡检) +- [十一、排错](#十一排错) +- [十二、配置项速查](#十二配置项速查) --- -## 一、部署方式怎么选 +## 一、三条部署路径怎么选 -| 场景 | 建议 | +| | 路径 A:裸机 | 路径 B:Docker 自打包 | 路径 C:compose 拉云端镜像 | +|---|---|---|---| +| **宿主机要装什么** | Python 3.11+ | Docker + Compose v2 | Docker + Compose v2 | +| **需要仓库源码吗** | 需要 | 需要 | **不需要**(只要一个 compose 文件) | +| **首次部署耗时** | 约 2 分钟 | 构建约 2~4 分钟 | 拉镜像约 30 秒 | +| **升级方式** | `git pull` + 重启服务 | `git pull` + `up -d --build` | 改 tag + `pull` + `up -d` | +| **适合** | Windows 工作站、没有 Docker 的机器、需要调试代码 | 自己维护镜像、要固定版本 | NAS / 服务器 / 只想跑起来 | +| **持久化位置** | 仓库下的 `data/`、`logs/` | 命名卷 `workbuddy-portal_wb_data` | 命名卷 `workbuddy-portal_wb_data` | + +**三者用的是同一份代码、同一个数据库格式**,所以可以在它们之间来回迁移 —— 数据正本只有 +一个文件 `usage.sqlite`(外加必须一起搬的 `instance.json`,见 [第八节](#八备份与恢复))。 + +> ### ⚠️ 不要横向扩展 +> SQLite 是**单写者**,调度线程也跑在 Web 进程内。多副本不会更快,只会带来 +> `database is locked` 竞争与**重复采集**。这个服务天然是单实例的。 +> 真要跑多份,除第一份之外全部设 `WB_DISABLE_SCHEDULER=1`。 + +--- + +## 二、部署前准备(三条路径共用) + +### 2.1 硬件与系统要求 + +| 项 | 要求 | |---|---| -| 有 Docker(NAS / 服务器 / 本机 Docker Desktop) | **Docker Compose**,最省事,升级只需换镜像 | -| 不想装 Docker,或要跑在 Windows 上用系统计划任务兜底 | 裸机 Python + `waitress` | -| 想给多人访问 | 任一种方式 + 反向代理(加 HTTPS 更稳) | +| CPU / 内存 | 任意 x86-64;内存在 256 MB 以上即可 | +| 磁盘 | ≥ 500 MB(镜像约 152 MB + 数据;数据库按每日约 1.5 MB 增长) | +| 系统 | Linux(x86-64 / arm64)、Windows 10+、macOS | +| 时区 | **必须是东八区**,否则「今日」口径与调度时刻都会错位(见 [11.6](#116-时区不对导致日期错位)) | -> **不要横向扩展**。SQLite 是单写者,调度线程也在 Web 进程内, -> 多副本只会带来锁竞争和重复采集。这个服务天然是单实例的。 +### 2.2 仓库目录结构 + +``` +workbuddy-portal/ +├── manage.py # 唯一入口(init / serve / collect / users / stats …) +├── requirements.txt +├── Dockerfile # 多阶段构建 +├── docker-compose.yml # 路径 B +├── docker-compose.hostdir.yml # 可选叠加层:数据放宿主机目录(只建议 Linux) +├── .env.example # 复制成 .env 再改 +├── docker/ +│ ├── entrypoint.sh # 容器入口:init → 可选导入 → serve +│ └── healthcheck.py # 只用标准库的健康检查 +├── workbuddy_portal/ # 应用代码 +│ ├── config.py # 启动期常量 + 配置作用域(GLOBAL_KEYS / USER_EDITABLE_KEYS) +│ ├── db.py # 建表 / 迁移 / 设置读写 +│ ├── crypto.py # Cookie 静态加密(零依赖 ChaCha20 + HMAC) +│ ├── captcha.py # 图形验证码(零依赖手写 PNG) +│ ├── collect.py # 采集(单写者锁) +│ ├── query.py # 聚合查询 +│ ├── scheduler.py # 进程内调度线程 +│ └── web/ # 蓝图 + 模板 + 静态资源 +├── data/ # 【运行时数据】正本 + exports/ + instance.json +├── logs/ # 【运行时数据】app.log(滚动 2 MB × 3) +├── backups/ # 数据库快照(人工/脚本产物,不入库) +├── docs/ # 本文档所在处,images/ 是手册配图 +└── tools/ # smoke.py / check_live.py / demo_data.py / shots.py / push-all.sh +``` + +同级的工作区根下还有一个 `legacy-v1/`(v1.0 单文件版归档,仅作迁移来源,可删), +详见它自带的 README。 + +### 2.3 先决定两件事 + +**① 首个管理员账号。** 数据库为空时才会创建,之后改密码走「用户管理」或 `manage.py passwd`。 + +```bash +# 默认是 admin / admin123 —— 局域网部署下必须改掉! +python manage.py init --user admin --password '你的强密码' +``` + +**② 访问方式。** 决定 `WB_BIND` 与 `WB_COOKIE_SECURE`: + +| 场景 | `WB_BIND` | `WB_COOKIE_SECURE` | +|---|---|---| +| 只本机用 | `127.0.0.1` | `0` | +| 局域网 `http://IP:8848` | `0.0.0.0` | **`0`**(设成 1 会导致「登录成功又跳回登录页」) | +| 域名 + HTTPS 反代 | `127.0.0.1` | `1` | + +### 2.4 从 v1.0 迁移历史数据(可选) + +如果你有旧版单文件脚本攒下的 CSV(`usage_records.csv`),新版本能直接导入: + +```bash +python manage.py migrate-csv # 自动按候选路径查找 +python manage.py migrate-csv /path/to/old.csv # 或显式指定 +python manage.py migrate-csv -u alice # 指定这份存档算谁的 +``` + +查找顺序定义在 `config.LEGACY_CSV_CANDIDATES`,工作区级 `legacy-v1/data/usage_records.csv` +是**第一候选**。导入是**只读**的 —— 不改动、不删除原 CSV,可以重复执行(主键去重)。 --- -## 二、Docker Compose 部署 +## 三、路径 A:裸机部署 -### 2.1 前置 +适合:Windows 工作站、没有 Docker 的机器、需要直接调试代码的场景。 -- Docker Engine 20.10+ / Docker Desktop(含 Compose v2) -- 至少 200 MB 磁盘(镜像 152 MB + 数据) - -### 2.2 步骤 +### 3.1 Linux ```bash git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git cd workbuddy-portal -cp .env.example .env -vi .env # 至少设置 WB_ADMIN_PASSWORD - -docker compose up -d --build -docker compose ps # 等 STATUS 变成 (healthy) -docker compose logs -f -``` - -浏览器打开 `http://<服务器IP>:8848`。 - -### 2.3 `.env` 主要变量 - -| 变量 | 默认 | 说明 | -|---|---|---| -| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` | -| `WB_PORT` | `8848` | 宿主机端口 | -| `TZ` | `Asia/Shanghai` | **影响「每日 09:00/17:00」与所有日期口径** | -| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) | -| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 | -| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」**,见 [第九节](#登录成功却立刻又跳回登录页) | -| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) | -| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | - -> `TZ` 与 `WB_COOKIE_SECURE` 都是**进程环境变量**,`docker compose restart` 不生效,要 `up -d`。 - -### 2.4 数据落点:用命名卷,不用绑定挂载 - -| 容器内 | 存放位置 | 内容 | -|---|---|---| -| `/app/data` | Docker 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(`secret_key` + `cookie_key`)、`exports/` | -| `/app/logs` | Docker 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) | - -**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的): - -> Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。 -> 宿主的 Windows 进程只要访问过这个 WAL 库——**哪怕只是 `manage.py stats` 这种纯读**—— -> 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`, -> 而且**不会自愈**,必须 `docker compose restart portal`。实测复现见 -> [第九节「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库)。 - -命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题, -所以容器独占数据目录是**唯一在所有平台上都正确**的做法。 - -**代价**:宿主机的 `manage.py` 不能直接读容器里的库了。随之而来的三件事都有一行命令替代: - -```bash -# 在容器里跑 CLI(推荐,始终打到同一份数据) -docker compose exec portal python manage.py stats -docker compose exec portal python manage.py vacuum -docker compose exec portal python manage.py passwd admin 新密码 - -# 看日志 -docker compose logs -f portal - -# 备份 / 恢复:见第六节 -``` - -#### 想要「宿主机直接看到数据/日志」怎么办 - -叠加 `docker-compose.hostdir.yml` 即可: - -```bash -docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d -# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录 -``` - -> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器是同一个文件系统, -> SQLite 的 WAL 与文件锁语义正常)。Windows + Docker Desktop 下会踩上面那个坑。 -> -> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 不一致: -> ```bash -> sudo chown -R 1000:1000 ./data ./logs -> ``` - -### 2.5 常用命令 - -```bash -docker compose ps -docker compose logs -f --tail=100 -docker compose restart -docker compose down # 停并删容器,数据保留 -docker compose up -d --build # 改完代码重新构建 - -# 在容器里跑 CLI(同一个数据卷) -docker compose exec portal python manage.py stats -docker compose exec portal python manage.py status -docker compose exec portal python manage.py passwd admin 新密码 -docker compose exec portal python manage.py vacuum -docker compose exec portal python manage.py collect # 手动采集一次 -``` - -> **本机调试**(Docker Desktop on Windows)已实测:命名卷上的 SQLite(WAL)读写正常, -> 启动补采、采集、导出、CSV 流式下载、健康检查全部可用; -> 且**宿主侧再跑 `manage.py` / `tools/smoke.py` 都不会影响容器**(这正是改用命名卷的原因)。 - ---- - -## 三、裸机部署 - -### 3.1 Windows - -```bat -git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git -cd workbuddy-portal -py -3 -m venv .venv -.venv\Scripts\pip install -r requirements.txt - -.venv\Scripts\python manage.py init --user admin --password 你的强密码 -.venv\Scripts\python manage.py serve -``` - -开机自启用「任务计划程序」:触发器「计算机启动时」,操作 -`<项目路径>\.venv\Scripts\python.exe`,参数 `manage.py serve`,起始位置设为项目目录。 - -### 3.2 Linux - -```bash -git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git -cd workbuddy-portal python3 -m venv .venv +.venv/bin/pip install --upgrade pip .venv/bin/pip install -r requirements.txt -.venv/bin/python manage.py init --user admin --password 你的强密码 + +# 首个管理员(只在数据库为空时生效) +.venv/bin/python manage.py init --user admin --password '你的强密码' + +# 先前台跑一次,确认能起 +.venv/bin/python manage.py serve +# 浏览器打开 http://:8848 ``` -`/etc/systemd/system/workbuddy-portal.service`: +跑通后交给 systemd(先 `Ctrl+C` 停掉前台进程): + +`/etc/systemd/system/workbuddy-portal.service` ```ini [Unit] @@ -178,12 +152,22 @@ Wants=network-online.target [Service] Type=simple User=workbuddy +Group=workbuddy WorkingDirectory=/opt/workbuddy-portal +# TZ 直接决定调度时刻与所有日期口径,务必设对 Environment=TZ=Asia/Shanghai +Environment=WB_COOKIE_SECURE=0 ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848 Restart=always RestartSec=5 +# ---- 加固(可选但建议)---- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/workbuddy-portal/data /opt/workbuddy-portal/logs + [Install] WantedBy=multi-user.target ``` @@ -195,15 +179,301 @@ sudo systemctl status workbuddy-portal journalctl -u workbuddy-portal -f ``` -> **不要**用 `manage.py collect` + cron 替代内置调度,除非你确实想让调度留在外部 -> (那种情况下 Web 端要加 `--no-scheduler`,避免和 cron 抢锁——虽然文件锁会保证正确性, -> 但会白跑一次)。 +> `User=workbuddy` 需要事先建号,并让 `data/`、`logs/` 归它所有: +> ```bash +> sudo useradd --system --home /opt/workbuddy-portal --shell /usr/sbin/nologin workbuddy +> sudo chown -R workbuddy:workbuddy /opt/workbuddy-portal/data /opt/workbuddy-portal/logs +> ``` + +### 3.2 Windows + +```bat +git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git +cd workbuddy-portal + +py -3 -m venv .venv +.venv\Scripts\pip install --upgrade pip +.venv\Scripts\pip install -r requirements.txt + +.venv\Scripts\python manage.py init --user admin --password 你的强密码 +.venv\Scripts\python manage.py serve +``` + +开机自启用「任务计划程序」: + +| 项 | 值 | +|---|---| +| 触发器 | 计算机启动时 | +| 操作 → 程序 | `<项目路径>\.venv\Scripts\python.exe` | +| 操作 → 参数 | `manage.py serve --host 0.0.0.0 --port 8848` | +| 操作 → 起始位置 | `<项目路径>` | +| 设置 | 勾「如果任务失败,按以下频率重新启动」,间隔 1 分钟,尝试 3 次 | + +> Windows 上 `--host 0.0.0.0` 首次启动会弹防火墙授权,选「专用网络」即可。 +> 系统时区务必是 `(UTC+08:00) 北京`,否则日期口径会错一天。 + +### 3.3 裸机部署的调度问题 + +调度线程在 **Web 进程内**,所以: + +- 停掉服务 = 停掉调度; +- **不建议**用 cron / 计划任务去跑 `manage.py collect` 代替内置调度。 + 真要走外部调度,就给 Web 端加 `--no-scheduler`,避免两边抢锁(文件锁能保证正确性, + 但会白跑一次)。 + +> 关机期间错过的时刻,靠「启动补跑」找回:下次启动时,已错过、且还在 +> `catch_up_grace_hours`(默认 12 小时)宽限期内的槽位会自动补采一次。 --- -## 四、反向代理与 HTTPS +## 四、路径 B:Docker 自打包部署 -前面挂 nginx 时要注意两点,否则会踩坑: +适合:自己维护镜像、要固定版本、要推送到自己的注册表。**从源码构建镜像。** + +### 4.1 前置 + +- Docker Engine 20.10+(含 Compose v2) +- 磁盘:镜像 152 MB + 构建缓存 + +### 4.2 步骤 + +```bash +git clone https://git.iwali.top/wangchuanli/workbuddy-portal.git +cd workbuddy-portal + +cp .env.example .env +vi .env # 至少设置 WB_ADMIN_PASSWORD + +docker compose up -d --build +docker compose ps # 等 STATUS 变成 (healthy) +docker compose logs -f +``` + +浏览器打开 `http://<服务器IP>:8848`。 + +### 4.3 构建方式 + +`Dockerfile` 是**多阶段构建**,不是随手写的: + +| 阶段 | 做什么 | 为什么 | +|---|---|---| +| `builder` | 只 `pip install -r requirements.txt` 到 `/opt/venv` | 依赖层与源码层解耦:**改业务代码不会触发重装依赖**,重建通常十几秒 | +| `runtime` | `python:3.13-slim` + 拷 venv + 非 root 用户 `app(uid 1000)` | 镜像里不留 pip 缓存与编译工具;进程不以 root 跑 | + +另外:装 `tzdata` 并 `ln -snf` 到 `/etc/localtime`(否则容器内 `TZ` 不生效, +调度时刻会错),`PYTHONDONTWRITEBYTECODE=1`(不在卷里留 `__pycache__`), +`init: true`(tini 接管 PID 1,`docker stop` 能干净传到 python)。 + +### 4.4 `.env` 主要变量 + +| 变量 | 默认 | 说明 | +|---|---|---| +| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址。只想本机访问就设 `127.0.0.1` | +| `WB_PORT` | `8848` | 宿主机端口 | +| `TZ` | `Asia/Shanghai` | **影响调度时刻与所有日期口径** | +| `WB_ADMIN_USER` | `admin` | 首个管理员用户名(只在库为空时生效) | +| `WB_ADMIN_PASSWORD` | 空 | 首个管理员密码。**留空会用 `admin123`**,务必显式设置 | +| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS。**纯 HTTP 部署设成 `1` 会导致「登录成功又跳回登录页」** | +| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) | +| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie | +| `WB_IMPORT_XLSX` | 空 | 启动时一次性导入这个路径的官网 xlsx | +| `WB_IMAGE` | `git.iwali.top/…:latest` | 镜像名(见 [第七节](#七把代码与镜像推到-gitea-注册表)) | + +> `TZ`、`WB_COOKIE_SECURE`、`WB_DISABLE_SCHEDULER` 都是**进程环境变量**, +> `docker compose restart` **不生效**,必须 `docker compose up -d` 重建容器。 + +### 4.5 数据落点:命名卷,不是绑定挂载 + +| 容器内 | 存哪 | 内容 | +|---|---|---| +| `/app/data` | 命名卷 `workbuddy-portal_wb_data` | `usage.sqlite`(正本)、`instance.json`(密钥)、`exports/` | +| `/app/logs` | 命名卷 `workbuddy-portal_wb_logs` | `app.log`(滚动 2 MB × 3) | + +**为什么是命名卷而不是脚本目录里的 `./data`**(这不是随手选的): + +> Windows + Docker Desktop 的绑定挂载走 **9p**(`aname=drvfs;path=C:\`)。 +> 宿主的 Windows 进程只要访问过这个 WAL 库 —— **哪怕只是 `manage.py stats` 这种纯读** —— +> 容器侧下一次打开就会 `sqlite3.OperationalError: unable to open database file`, +> 而且**不会自愈**,必须重启容器。复现步骤见 [11.5](#115-windows-绑定挂载的坑容器打不开数据库)。 + +命名卷住在 Linux VM 的本地文件系统里,不存在跨文件系统翻译的问题, +所以**容器独占数据目录是唯一在所有平台上都正确的做法**。 + +**代价**:宿主机的 `manage.py` 不能直接读容器里的库。替代做法都是一行命令: + +```bash +docker compose exec portal python manage.py stats +docker compose exec portal python manage.py vacuum +docker compose exec portal python manage.py passwd admin 新密码 +docker compose logs -f portal +``` + +#### 想让宿主机直接看到数据 / 日志 + +叠加 `docker-compose.hostdir.yml`: + +```bash +docker compose -f docker-compose.yml -f docker-compose.hostdir.yml up -d +# 可用 WB_HOST_DATA_DIR / WB_HOST_LOG_DIR 指定目标目录 +``` + +> ⚠️ **只建议 Linux 宿主机使用**(bind mount 与容器同一文件系统,WAL 语义正常)。 +> Windows + Docker Desktop 下必然踩上面那个坑。 +> +> Linux 首次运行若报 `unable to open database file`,是宿主目录属主与容器内 uid 1000 +> 不一致:`sudo chown -R 1000:1000 ./data ./logs` + +### 4.6 常用命令 + +```bash +docker compose ps +docker compose logs -f --tail=100 +docker compose restart # 注意:环境变量改动 restart 不生效 +docker compose down # 停并删容器,数据保留在卷里 +docker compose up -d --build # 改完代码重新构建 + +docker compose exec portal python manage.py collect # 手动采集一次 +docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 +docker compose exec portal python manage.py status # 调度与最近采集 +``` + +--- + +## 五、路径 C:docker-compose 拉云端镜像部署 + +适合:NAS、服务器、**不想 clone 仓库**、只想尽快跑起来。镜像由 [第七节](#七把代码与镜像推到-gitea-注册表) +推送到 Gitea 注册表,这里只负责拉下来运行。 + +### 5.1 前置 + +- Docker + Compose v2 +- 能访问 `git.iwali.top`(有 Let's Encrypt 通配证书,**HTTPS 直接可用, + 不需要配 `insecure-registries`**) + +### 5.2 建目录、写两个文件 + +```bash +mkdir -p /opt/workbuddy-portal && cd /opt/workbuddy-portal +``` + +**`docker-compose.yml`**(完整可用,不依赖仓库里的其它文件): + +```yaml +name: workbuddy-portal + +services: + portal: + image: git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 + container_name: workbuddy-portal + restart: unless-stopped + init: true # tini 接管 PID 1,docker stop 能干净传到 python + ports: + - "${WB_BIND:-0.0.0.0}:${WB_PORT:-8848}:8848" + environment: + TZ: ${TZ:-Asia/Shanghai} # 决定调度时刻与所有日期口径 + WB_HOST: 0.0.0.0 + WB_PORT: "8848" + WB_ADMIN_USER: ${WB_ADMIN_USER:-admin} + WB_ADMIN_PASSWORD: ${WB_ADMIN_PASSWORD:-} + WB_DISABLE_SCHEDULER: ${WB_DISABLE_SCHEDULER:-0} + # 纯 HTTP 部署必须留 0,设成 1 会「登录成功又跳回登录页」 + WB_COOKIE_SECURE: ${WB_COOKIE_SECURE:-0} + WB_IMPORT_CREDS: ${WB_IMPORT_CREDS:-0} + volumes: + - wb_data:/app/data # 数据正本 + instance.json + exports + - wb_logs:/app/logs + healthcheck: + test: ["CMD", "python", "/app/docker/healthcheck.py"] + interval: 30s + timeout: 6s + start_period: 20s + retries: 3 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + wb_data: + wb_logs: +``` + +**`.env`**: + +```bash +cp /dev/null .env +cat >> .env <<'EOF' +WB_BIND=0.0.0.0 +WB_PORT=8848 +TZ=Asia/Shanghai + +# 首个管理员(只在数据库为空时生效)—— 务必改掉默认值 +WB_ADMIN_USER=admin +WB_ADMIN_PASSWORD=换成你的强密码 + +WB_DISABLE_SCHEDULER=0 +WB_COOKIE_SECURE=0 +WB_IMPORT_CREDS=0 +EOF +chmod 600 .env # 里面有密码 +``` + +### 5.3 登录注册表并启动 + +```bash +# 如果镜像是公开仓库,可以跳过 login +docker login git.iwali.top -u wangchuanli +# 密码填 Gitea Personal Access Token(「设置 → 应用 → 生成令牌」,勾 write:package) + +docker compose pull +docker compose up -d +docker compose ps # 等 STATUS 变成 (healthy) +docker compose logs -f --tail=50 +``` + +首次启动时容器入口会依次做三件事(见 `docker/entrypoint.sh`): + +1. `manage.py init` —— 建表 + 灌默认配置 + 创建首个管理员(**幂等**,库非空时不重建); +2. 可选导入 —— `WB_IMPORT_CREDS=1` / `WB_IMPORT_XLSX=…` 才触发; +3. `exec manage.py serve` —— 交接给 waitress,调度线程就在这个进程里。 + +日志里看到 `[entrypoint] 启动 Web 服务(waitress)…` 就是起来了。 + +### 5.4 完成后的检查清单 + +```bash +# 1) 健康状态 +docker compose ps # STATUS 应为 Up (healthy) + +# 2) 时区正确 +docker compose exec portal date # 应输出 CST / +0800 + +# 3) 数据库已就绪,且实例级配置齐全 +docker compose exec portal python manage.py status +docker compose exec portal python manage.py users + +# 4) 浏览器打开,用管理员登录 → 立刻改密码 +# http://<服务器IP>:8848 +``` + +> **登录后第一件事是改密码**:`admin/admin123` 是公开的默认值。 +> 「个人中心 → 修改登录密码」,或 `docker compose exec portal python manage.py passwd admin 新密码`。 + +### 5.5 升级(本路径最省事) + +```bash +# 改 docker-compose.yml 里的 tag,或 +docker compose pull # 拉 latest +docker compose up -d # 重建容器;数据在命名卷里不受影响 +docker compose exec portal python manage.py status +``` + +--- + +## 六、反向代理与 HTTPS + +前面挂 nginx / Caddy / Traefik 时,有两点必须注意,否则会踩坑: ```nginx server { @@ -217,39 +487,42 @@ server { proxy_pass http://127.0.0.1:8848; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - # 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上 + # 必须透传:登录失败限速按真实 IP 计数,否则所有请求都算到代理头上, + # 一个人被锁 → 全站被锁 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 导出 CSV 已带 X-Accel-Buffering: no,这里关掉代理缓冲才能边查边吐 proxy_buffering off; - proxy_read_timeout 300s; # 采集/导出可能跑几分钟 + proxy_read_timeout 300s; # 采集 / 导出可能跑几分钟 } } ``` -排错要点: - | 现象 | 原因 | |---|---| | 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` | | 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` | | 手动采集走到 504 | `proxy_read_timeout` 太短 | +配好 HTTPS 后,把 `.env` 里的 `WB_COOKIE_SECURE` 改成 `1`(会话 Cookie 只走 HTTPS), +然后 `docker compose up -d` 重建容器。**只有真的能用 `https://` 访问时才这么做。** + --- -## 五、把代码与镜像推到 Gitea +## 七、把代码与镜像推到 Gitea 注册表 -Gitea 自带容器注册表(`registry/2.0`)。代码仓库与镜像的目标都是 `git.iwali.top/wangchuanli/workbuddy-portal`。 +路径 C 需要的镜像就是在这里产出的。代码仓库与镜像的目标都是 +`git.iwali.top/wangchuanli/workbuddy-portal`。 -### 5.0 一条命令推代码 + 推镜像 +### 7.1 一条命令推代码 + 推镜像 -准备好 Gitea **Access Token**(「设置 → 应用 → 生成令牌」,勾选 `repo` + `write:package`)后: +先在 Gitea「设置 → 应用 → 生成令牌」创建 Access Token,勾选 `repo` + `write:package`: ```bash export GITEA_TOKEN=<你的令牌> -tools/push-all.sh # 推 main 分支 + 镜像 latest -tools/push-all.sh 1.1.0 # 同时打一个版本 tag 并推送 +tools/push-all.sh # 推 main 分支 + 镜像 latest +tools/push-all.sh 1.3.0 # 同时打一个版本 tag 并推送 ``` 脚本做的事: @@ -260,84 +533,42 @@ tools/push-all.sh 1.1.0 # 同时打一个版本 tag 并推送 2. `docker compose build` —— 镜像名本身就是注册表地址。 3. `docker login` + `docker push`(`--password-stdin`,Token 不进命令行历史)。 -> 不用脚本、手工推也行:`git push origin main` 弹出凭据窗口时, -> 用户名填 `wangchuanli`,**密码处填 Access Token**(不是网页登录密码)。 +> 不用脚本、手工推也行:`git push origin main` 弹出凭据窗口时,用户名填 `wangchuanli`, +> **密码处填 Access Token**(不是网页登录密码)。 -### 5.1 传输协议:默认走 HTTPS(不用配 insecure-registries) +### 7.2 传输协议:默认走 HTTPS -`git.iwali.top` 有 **Let's Encrypt 通配证书(`*.iwali.top`)**,HTTPS 全程可用: +`git.iwali.top` 有 **Let's Encrypt 通配证书(`*.iwali.top`)**: ```bash curl -s -o /dev/null -w "%{http_code}\n" https://git.iwali.top/api/v1/version # 200 curl -s -o /dev/null -w "%{http_code}\n" https://git.iwali.top/v2/ # 401(需认证,正常) ``` -所以: +所以 git 远端用 `https://`,镜像名就是 `git.iwali.top/…`,Docker 默认按 HTTPS 访问, +**无需任何额外配置**。 -- **git 远端用 `https://`**(仓库地址见上); -- **镜像名就是 `git.iwali.top/...`,Docker 默认按 HTTPS 访问** —— 无需任何额外配置。 +> 只有在 Gitea 前面**没有** TLS 终止(纯 `http://`)时,才需要在 daemon.json 里加 +> `{"insecure-registries": ["git.iwali.top"]}`。明文传输会让 Token 暴露在网络里, +> **能走 HTTPS 就不要开这个口子**。 -> 只有在 Gitea 前面**没有** TLS 终止(纯 `http://`)时,才需要在 Docker Desktop -> **Settings → Docker Engine** 的 `daemon.json` 里加: -> ```json -> { "insecure-registries": ["git.iwali.top"] } -> ``` -> 然后 `Apply & Restart`(Linux 改 `/etc/docker/daemon.json` 后重启 docker)。 -> 明文传输会让 Token 暴露在网络里,**能走 HTTPS 就不要开这个口子**。 - -### 5.2 登录 +### 7.3 手工构建与推送 ```bash docker login git.iwali.top -u wangchuanli -# 密码用 Personal Access Token(Gitea「设置 → 应用 → 生成令牌」, -# 勾选 write:package;不要用网页登录密码) -``` +# 密码用 Personal Access Token,勾 write:package -### 5.3 构建并推送 - -```bash -# 镜像名默认已经是注册表地址(见 docker-compose.yml 的 image: 字段) docker compose build - -# 打上语义化版本标签 docker tag git.iwali.top/wangchuanli/workbuddy-portal:latest \ - git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 - + git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 docker push git.iwali.top/wangchuanli/workbuddy-portal:latest -docker push git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 +docker push git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 ``` -### 5.4 在另一台机器上拉取运行 +### 7.4 验证远端 ```bash -docker pull git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 - -# 不 clone 仓库也能跑:只写一个 compose 文件 -cat > docker-compose.yml <<'YAML' -services: - portal: - image: git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 - restart: unless-stopped - ports: ["8848:8848"] - environment: - TZ: Asia/Shanghai - WB_ADMIN_PASSWORD: "改成你的强密码" - volumes: - - wb_data:/app/data - - wb_logs:/app/logs - -volumes: - wb_data: - wb_logs: -YAML - -docker compose up -d -``` - -### 5.5 验证远端 - -```bash -docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.1.0 +docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.3.0 # 或 curl -s -u wangchuanli:TOKEN \ https://git.iwali.top/api/v1/packages/wangchuanli?type=container @@ -345,63 +576,77 @@ curl -s -u wangchuanli:TOKEN \ --- -## 六、备份与恢复 +## 八、备份与恢复 -### 6.1 备份什么 +### 8.1 备份什么 数据都在命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`): | 文件 | 重要性 | 说明 | |---|---|---| -| `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 | -| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** | -| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了/被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需要各自重填** | +| `usage.sqlite` | ★★★ | **数据正本**。丢了要重新采集,且**官网窗口之外的数据永久丢失** | +| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷**(或用 8.2 的方式先 checkpoint) | +| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了 / 被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需各自重填** | | `exports/*.csv` | ★ | 导出快照,可再生 | -| `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 | +| `wb_logs` 卷 | ☆ | 排错用,可再生 | -`.env` 不在里面——它含密码,**单独用密码管理器保管**。 +`.env` 不在里面 —— 它含密码,**单独用密码管理器保管**。 -> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 2.4 与第九节)。 +工程目录下的 `backups/` 就是给这类快照准备的位置(**刻意不放在 `data/`**: +`data/` 是 Docker 卷,`docker compose down -v` 会把备份和正本一起删掉)。 -### 6.2 备份 +> **不要**在容器运行时用宿主机的 `manage.py` 去碰库(见 [4.5](#45-数据落点命名卷不是绑定挂载) 与 [11.5](#115-windows-绑定挂载的坑容器打不开数据库))。 -**方式 A:整体打包命名卷(推荐,最完整)** +### 8.2 备份 + +**方式 A:整体打包命名卷(最完整,升级前做)** ```bash docker compose stop portal docker run --rm \ -v workbuddy-portal_wb_data:/data:ro \ - -v "$PWD/backup":/backup \ + -v "$PWD/backups":/backup \ alpine:3.20 tar czf /backup/wb-data-$(date +%F).tar.gz -C /data . docker compose start portal ``` -**方式 B:只导数据库文件(最常用)** +**方式 B:只导数据库文件(最常用,每天做)** ```bash -# 先 checkpoint,把 WAL 落进主库,再拷——这样单独一个 .sqlite 就是完整的 +# 先 checkpoint 把 WAL 落进主库,再拷 —— 这样单独一个 .sqlite 就是完整的 docker compose exec portal python -c " from workbuddy_portal import db db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')" docker run --rm \ -v workbuddy-portal_wb_data:/data:ro \ - -v "$PWD/backup":/backup \ + -v "$PWD/backups":/backup \ alpine:3.20 cp /data/usage.sqlite /backup/usage-$(date +%F).sqlite ``` +> 也可以用 SQLite 官方的在线备份 API,**不用停服务、不用手工 checkpoint**: +> ```bash +> docker compose exec -T portal python -c " +> import sqlite3 +> s = sqlite3.connect('/app/data/usage.sqlite'); d = sqlite3.connect('/tmp/b.sqlite') +> s.backup(d); d.close(); s.close()" +> docker compose cp portal:/tmp/b.sqlite ./backups/usage-$(date +%F).sqlite +> docker compose exec -T portal rm -f /tmp/b.sqlite +> ``` + **方式 C:逻辑导出(跨版本最安全,可读性最好)** ```bash docker compose exec portal python manage.py export-csv /app/data/exports -docker compose cp portal:/app/data/exports/. ./backup/exports/ +docker compose cp portal:/app/data/exports/. ./backups/exports/ ``` -建议方式 B 每天跑、方式 C 每季度跑一次;方式 A 在升级前跑。 +建议:方式 B 每天跑(可用宿主机 cron)、方式 C 每季度跑一次、方式 A 在**升级前**跑。 +裸机部署把上面命令里的 `docker compose exec portal` 去掉即可,路径换成相对的 `data/`。 -### 6.3 恢复 +### 8.3 恢复 ```bash docker compose down @@ -411,20 +656,22 @@ docker volume rm workbuddy-portal_wb_data docker volume create workbuddy-portal_wb_data docker run --rm \ -v workbuddy-portal_wb_data:/to \ - -v "$PWD/backup":/from:ro \ + -v "$PWD/backups":/from:ro \ alpine:3.20 sh -c 'cp -a /from/. /to/' docker compose up -d -docker compose exec portal python manage.py stats # 核对条数 +docker compose exec portal python manage.py stats # 核对条数与积分 ``` > **别把旧库和旧 WAL 混着用**:WAL 里记的是相对旧库的增量,配错会损坏数据。 -> 用方式 B 的备份(已 checkpoint)最省心,恢复时目标目录里只放一个 `.sqlite` 即可。 +> 恢复时目标目录里**只放一个 `.sqlite`**(外加 `instance.json`)最省心。 +> +> 只恢复了库、没恢复 `instance.json` 的话,所有账号的 Cookie 都会显示「无法解密」, +> 各自重新粘贴一次即可 —— 历史用量数据不受影响。 -### 6.4 迁移:从绑定挂载换到命名卷 +### 8.4 迁移:从绑定挂载换到命名卷 -如果之前用的是 `docker-compose.hostdir.yml`(或旧版把 `./data` 直接挂进去),数据在 -宿主机目录里,搬进命名卷: +如果之前用了 `docker-compose.hostdir.yml`(或旧版把 `./data` 直接挂进去): ```bash docker compose down @@ -437,25 +684,27 @@ docker compose up -d # 不带 -f hostdir 叠加层 docker compose exec portal python manage.py stats ``` -> 迁移完成后,宿主机上的 `./data` 就不再被容器使用了,可以留作迁移前的冷备。 +迁移完成后,宿主机的 `./data` 不再被容器使用,留作迁移前的冷备即可。 --- -## 七、升级与回滚 +## 九、升级与回滚 + +### 升级前 + +1. **先备份**(见 [第八节](#八备份与恢复))。备份要**同时包含 `usage.sqlite` 与 `instance.json`** + —— 后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。 +2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更(`主版本` 与标了 + **变更(不兼容)** 的条目)。 +3. 记下当前版本:`docker compose exec portal python -c "import workbuddy_portal;print(workbuddy_portal.__version__)"` ### Docker ```bash -git pull -docker compose build -docker compose up -d # 重建容器,数据在挂载卷里不受影响 -docker compose exec portal python manage.py stats -``` - -回滚:把 `.env` 里的 `WB_IMAGE` 指回旧版本标签,然后 - -```bash -docker compose up -d --no-build +git pull # 路径 C 跳过这步 +docker compose build # 路径 C 改用 docker compose pull +docker compose up -d # 重建容器;数据在命名卷里不受影响 +docker compose exec portal python manage.py status ``` ### 裸机 @@ -466,59 +715,94 @@ git pull sudo systemctl restart workbuddy-portal ``` -### 升级前 - -1. **先备份**(见第六节)。备份要**同时包含 `usage.sqlite` 与 `instance.json`** —— - 后者存着凭证加密主密钥,只备库不备它,恢复后所有 Cookie 都要重填。 -2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。 - -### 1.1.0 → 1.2.0(单用户 → 多用户) - -**无需任何手工迁移命令。** 首次用新版启动时会自动完成,日志里能看到: - -| 做了什么 | 效果 | -|---|---| -| 建 `users` 表、写入首个管理员 | 用 `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD`,或沿用 `admin` / `admin123` | -| `settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键 | 老数据整体归到**第一个账号** | -| 明文 Cookie 就地加密 | 日志记一条 `明文凭证已加密:settings[uid=1].cookie` | -| 建 `captchas` 表、补索引 | 验证码用 | - -迁移由 `PRAGMA user_version` 驱动,**幂等**:重复启动不会重复执行。 -校验一下: +### 回滚 ```bash -docker compose logs portal | grep -i "迁移\|migrat" -docker compose exec portal python manage.py users # 账号 / 角色 / 数据量 / 凭证状态 -docker compose exec portal python manage.py stats # 各账号条数与积分 +# Docker:把 .env 的 WB_IMAGE 或 compose 里的 tag 指回旧版本 +docker compose up -d --no-build + +# 裸机 +git checkout <旧版本 tag> +.venv/bin/pip install -r requirements.txt +sudo systemctl restart workbuddy-portal +``` + +> ### ⚠️ 回滚数据库前先看 `user_version` +> 迁移由 `PRAGMA user_version` 驱动,每次结构变更都会 +1。**回滚到更老的代码**时, +> 服务会认为库"版本过高"而拒绝启动(或反之重跑迁移)。回滚代码的同时要把库一起回滚, +> 用升级前那份快照。 +> +> 想确认当前库结构版本: +> ```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])" +> ``` + +### 迁移历史 + +| 版本跨度 | `user_version` | 自动做了什么 | 要手工介入吗 | +|---|---|---|---| +| **1.1.0 → 1.2.0**(单用户 → 多用户) | 0 → 2 | 建 `users` 表并写入首个管理员;`settings` / `usage_records` / `collect_runs` / `audit_log` 改为 `(user_id, …)` 复合主键,**老数据整体归到第一个账号**;明文 Cookie **就地加密**;建 `captchas` 表 | 不需要 | +| **1.2.0 → 1.3.0**(权限收敛) | 2 → 3 | **配置作用域收敛**:把管理员个人名下的调度与采集参数提升到实例级 `user_id=0`,再清掉个人残留;实例级不再保留 `cookie` / `user_agent` | 不需要 | + +两次迁移都是**幂等**的(可重复启动、可重复执行),各自会写一条审计: + +```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 # 各账号条数与积分,核对零丢失 ``` > 升级后请**确认 Cookie 能解密**:登录后打开「配置管理 → 我的云端凭证」, -> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「无法解密」,说明 `instance.json` -> 不匹配,重新粘贴一次即可。 +> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「**无法解密**」,说明 +> `instance.json` 不匹配,各自重新粘贴一次即可。 --- -## 八、日常巡检 +## 十、日常巡检 | 频率 | 做什么 | |---|---| | 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 | -| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警 | -| 每月 | 确认 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 | +| 每周 | 「日志管理」按 `warn` / `error` 筛一遍,看有没有 TLS 或解密类告警(**仅管理员**) | +| 每月 | 确认各账号 Cookie 没过期(「配置管理」看提示);跑一次备份恢复演练 | | 每季度 | `manage.py vacuum`;出一份全量 CSV 归档;检查磁盘占用 | 一键体检: ```bash -docker compose exec portal python manage.py status -docker compose exec portal python manage.py stats +docker compose exec portal python manage.py status # 调度与最近采集 +docker compose exec portal python manage.py stats # 存档概况 + 逐账号明细 +docker compose exec portal python manage.py users # 账号 / 角色 / 凭证状态 ``` +磁盘长这样:数据库每 1000 条记录约 1.5 MB,`app.log` 滚动上限 2 MB × 3。 +增长慢,但**年度归档后记得对旧数据做取舍**(本项目不做自动清理,数据只增不减)。 + +### 自检脚本(开发者向) + +仓库自带三层验证,改完代码或升级后可以跑: + +```bash +.venv/bin/python tools/check_docs.py # 文档自检:链接 / 锚点 / 图片 / 绝对路径泄漏 / 版本一致 +.venv/bin/python tools/smoke.py # 215 项:库层 + 页面渲染 + 权限模型(不启服务) +.venv/bin/python tools/check_live.py # 122 项:真实 HTTP,含 CSRF / 开放重定向 / 验证码 +.venv/bin/python tools/check_live.py --as alice:密码 # 追加普通账号越权验收 +``` + +> `smoke.py` 会**对真实库做写入测试**(哨兵值用完即还原)。跑之前先备份, +> 或在示例库上跑:`python tools/demo_data.py` 会另建一份 `data/demo/usage.sqlite`。 +> +> ⚠️ 在示例库上跑 `check_live.py` 时**必须显式加 `--db data/demo/usage.sqlite`** —— +> 它的 `--db` 默认值是真实的 `data/usage.sqlite`,不传会从错误的库里取验证码答案, +> 表现为「登录失败」加一串与真实原因无关的断言失败。 + --- -## 九、排错 +## 十一、排错 -### 容器起来了但页面打不开 +### 11.1 容器起来了但页面打不开 ```bash docker compose ps # 看 STATUS 是否 (healthy) @@ -528,17 +812,16 @@ docker compose logs --tail=100 | 症状 | 排查 | |---|---| | `STATUS` 是 `Restarting` | 看日志里的 Python traceback;多半是 `data/` 权限或端口冲突 | -| `unhealthy` 但 `Up` | 健康检查打 `/login` 失败;`docker compose exec portal python /app/docker/healthcheck.py` 看具体报错 | +| `unhealthy` 但 `Up` | 健康检查打 `/login` 失败:`docker compose exec portal python /app/docker/healthcheck.py` 看具体报错 | | 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` | | 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 | -### 登录成功却立刻又跳回登录页 +### 11.2 登录成功却立刻又跳回登录页 几乎一定是 `WB_COOKIE_SECURE` 被设成了 `1`,而你在用 **HTTP** 访问。 -会话 Cookie 带 `Secure` 属性后,浏览器只在 HTTPS 下才回传;服务端每次都收不到会话, -就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」,日志里 -看起来像在反复登录。 +会话 Cookie 带 `Secure` 属性后浏览器只在 HTTPS 下回传;服务端每次都收不到会话, +就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」。 ```bash # .env @@ -546,30 +829,32 @@ WB_COOKIE_SECURE=0 docker compose up -d # 环境变量,必须 up -d,restart 不生效 ``` -只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。 -(另:如果站点前后端域名不同,还要看第四节的反代配置。) +### 11.3 普通账号看不到「日志管理」/「用户管理」 -### `exec format error` / `no such file or directory`(entrypoint) +**这是设计如此,不是 bug。** 见 [用户指南 · 权限与数据边界](USER-GUIDE.md#三权限与数据边界)。 -`docker/entrypoint.sh` 被 CRLF 污染了。仓库里有 `.gitattributes` 强制 `*.sh` 为 LF; -若手工传过文件,执行: +| 入口 | 普通账号 | +|---|---| +| `/logs`、`/logs/tail` | 403(导航里不显示) | +| `/users`、`/api/users` | 403(导航里不显示) | +| 任务管理页的调度表单 | 只读(时刻由管理员统一设定) | +| 配置管理页的采集参数 | 只读 | +| 本人的 Cookie / User-Agent | **可读写**(唯一可改的配置) | + +改权限:`docker compose exec portal python manage.py passwd alice 密码 --role admin`。 + +### 11.4 `exec format error` / `no such file or directory`(entrypoint) + +`docker/entrypoint.sh` 被 CRLF 污染了。仓库有 `.gitattributes` 强制 `*.sh` 为 LF; +若手工传过文件: ```bash python -c "p='docker/entrypoint.sh';d=open(p,'rb').read();open(p,'wb').write(d.replace(b'\r\n',b'\n'))" ``` -### 数据库相关 +### 11.5 Windows 绑定挂载的坑:容器打不开数据库 -| 报错 | 原因 / 处理 | -|---|---| -| `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` | -| `unable to open database file`(**Windows + Docker Desktop**) | 见下面 [「Windows 绑定挂载的坑」](#windows-绑定挂载的坑容器打不开数据库);根治办法是用默认的命名卷 | -| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) | -| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 | - -### Windows 绑定挂载的坑:容器打不开数据库 - -**症状**:容器起来时一切正常,跑着跑着 `/` `/records` 全部 `500`,应用日志里是 +**症状**:容器起来时正常,跑着跑着 `/`、`/records` 全部 500,应用日志里是 ``` File "/app/workbuddy_portal/db.py", line 33, in connect @@ -598,41 +883,52 @@ docker compose exec portal python -c \ # -> sqlite3.OperationalError: unable to open database file ``` -关键点:**纯读也会触发**,而且**宿主进程退出后容器不会自愈**—— -只有 `docker compose restart portal` 才恢复。 +关键点:**纯读也会触发**,而且**宿主进程退出后容器不会自愈** —— 只有重启容器才恢复。 **处理**: -1. **根治(推荐)**:不要用 `hostdir` 叠加层,直接用默认的**命名卷** - (`docker-compose.yml`)。容器独占 `/app/data`,宿主侧一律通过 - `docker compose exec portal python manage.py …` 操作。 +1. **根治(推荐)**:不要用 `hostdir` 叠加层,直接用默认的**命名卷**。 + 容器独占 `/app/data`,宿主侧一律通过 `docker compose exec portal python manage.py …` 操作。 2. **临时**:`docker compose restart portal` 立刻恢复,但下一次宿主访问会再坏一次。 -3. **想在宿主侧看数据**:用第六节的方式导出到宿主目录再看,不要直接连库。 +3. **想在宿主侧看数据**:用 [8.2](#82-备份) 的方式导出到宿主目录再看,不要直接连库。 -> Linux 宿主机上不存在这个问题(bind mount 与容器是同一个文件系统), +> Linux 宿主机上不存在这个问题(bind mount 与容器是同一文件系统), > 所以 `docker-compose.hostdir.yml` 对 Linux 是安全的。 -### 采集相关 - -| 报错 | 处理 | -|---|---| -| `cookie_expired` / `401` | 重新获取 Cookie 填进「配置管理」,然后按区间补采 | -| `TLS` / `SSLError` | 企业代理 / 自签证书场景,临时把 `ssl_verify` 设为 `0`;否则保持开启 | -| 返回 `409 busy` | 正常——已有采集在跑,等它结束 | -| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 | - -### 时区不对导致日期错位 +### 11.6 时区不对导致日期错位 ```bash docker compose exec portal date # 应该输出 CST / +0800 ``` 若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,改完 `docker compose up -d` 重建容器 -(`TZ` 是环境变量,`restart` 不生效)。 +(`TZ` 是环境变量,`restart` 不生效)。裸机部署确认系统时区。 -### 想临时关掉自动采集 +### 11.7 数据库相关 -「任务管理 → 取消勾选『启用调度』→ 保存」。或者 +| 报错 | 原因 / 处理 | +|---|---| +| `unable to open database file`(**用了 `hostdir` 叠加层**) | 宿主目录属主与容器内 uid 1000 不一致:`sudo chown -R 1000:1000 ./data` | +| `unable to open database file`(**Windows + Docker Desktop**) | 见 [11.5](#115-windows-绑定挂载的坑容器打不开数据库);根治办法是改用命名卷 | +| `database is locked` | 有另一个写进程;等它跑完(文件锁会串行化,但 SQLite 层仍会短暂报错) | +| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 | +| 库体积异常大 | `manage.py vacuum` 回收空间 | + +### 11.8 采集相关 + +| 报错 | 处理 | +|---|---| +| `cookie_expired` / `401` | 让**该账号本人**重新获取 Cookie 填进「配置管理」,然后按区间补采 | +| `no_cookie` | 该账号还没配 Cookie(新注册的账号都属于这种),本人粘贴一次即可 | +| `cookie_broken` | 密文解不开(`instance.json` 被换过),本人重新粘贴一次 | +| `TLS` / `SSLError` | 企业代理 / 自签证书场景才临时把 `ssl_verify` 设为 `0`;否则保持开启 | +| 返回 `409 busy` | 正常 —— 已有采集在跑,等它结束 | +| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 | +| 多副本重复采集 | 除第一份外全部设 `WB_DISABLE_SCHEDULER=1` | + +### 11.9 想临时关掉自动采集 + +「任务管理 → 取消勾选『启用调度』→ 保存」(**仅管理员**)。或者临时改环境变量: ```bash echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d @@ -640,47 +936,88 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d --- -## 十、配置项速查 +## 十二、配置项速查 -调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改, -也可以直接改表)。表的主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用, -仅管理员可改),其余是**个人级**(每个账号一份,互不可见)。 +### 12.1 数据库里的配置(改完立即生效,不用重启) -| 键 | 默认 | 作用域 | 说明 | -|---|---|---|---| -| `api_base` / `api_path` | 官方地址 | **实例级** | 接口地址(走镜像/代理时改) | -| `allow_register` | `1` | **实例级** | 是否开放自助注册 | -| `register_max_per_ip` | `3` | **实例级** | 同一 IP 每日注册上限(1~50) | -| `captcha_policy` | `always` | **实例级** | `always` / `adaptive` / `off` | -| `captcha_length` | `4` | **实例级** | 验证码位数(4~6) | -| `cookie` | 空 | 个人级 | 账号凭证,**密文入库**;页面只回「长度 + 结尾 4 位」 | -| `user_agent` | Chrome UA | 个人级 | 与 Cookie 同源更稳。**`cookie` 与 `user_agent` 不参与实例级回落**(回落 = 串号越权) | -| `schedule_enabled` | `1` | 个人级 | 调度总开关 | -| `schedule_times` | `09:00,17:00` | 个人级 | 每日时刻,逗号分隔,本地时区 | -| `catch_up` | `1` | 个人级 | 启动补跑开关 | -| `catch_up_grace_hours` | `12` | 个人级 | 补跑宽限期(小时) | -| `page_size` | `200` | 个人级 | 采集单页条数(20~1000) | -| `rewind_minutes` | `2` | 个人级 | 断点回退分钟数(0~120) | -| `drift_tolerance_minutes` | `5` | 个人级 | 云端时间漂移告警阈值(0~720) | -| `max_prompt` | `2048` | 个人级 | Prompt 入库截断长度,0 = 不截断 | -| `verify_days` | `0` | 个人级 | 采集后整日校验天数(0~90) | -| `timeout` | `30` | 个人级 | HTTP 超时秒数(5~300) | -| `ssl_verify` | `1` | 个人级 | 校验云端 HTTPS 证书 | +表主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用),其余是**个人级**。 -**读配置时有三级回落**:个人级 → 实例级 → 代码里的 `DEFAULTS`。 -所以实例级的值只是「默认值」,任何账号都可以用自己的值覆盖它(`cookie` / `user_agent` 例外)。 +**v1.3.0 起只有两个键是个人级的**(`cookie` / `user_agent`)—— 普通账号唯一能改的东西。 +写权限判断统一走 `config.writable_by(key, is_admin)`,页面与接口用的是同一个函数。 -**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒—— +| 键 | 默认 | 作用域 | 谁能改 | 说明 | +|---|---|---|---|---| +| `cookie` | 空 | 个人级 | **所有登录用户**(仅本人) | 账号凭证,**密文入库**;页面只回「长度 + 结尾 4 位」 | +| `user_agent` | Chrome UA | 个人级 | **所有登录用户**(仅本人) | 与 Cookie 同源更稳。**不参与实例级回落**(回落 = 串号越权) | +| `api_base` | 官方地址 | 实例级 | 仅管理员 | 接口基址(走镜像 / 代理时改) | +| `api_path` | `/billing/meter/get-user-request-usage` | 实例级 | 仅管理员 | 接口路径 | +| `allow_register` | `1` | 实例级 | 仅管理员 | 是否开放自助注册 | +| `register_max_per_ip` | `3` | 实例级 | 仅管理员 | 同一 IP 每日注册上限(1~50) | +| `captcha_policy` | `always` | 实例级 | 仅管理员 | `always` / `adaptive` / `off` | +| `captcha_length` | `4` | 实例级 | 仅管理员 | 验证码位数(4~6) | +| `schedule_enabled` | `1` | 实例级 | 仅管理员 | 调度总开关 | +| `schedule_times` | `09:00,17:00` | 实例级 | 仅管理员 | 每日时刻,逗号分隔,本地时区 | +| `catch_up` | `1` | 实例级 | 仅管理员 | 启动补跑开关 | +| `catch_up_grace_hours` | `12` | 实例级 | 仅管理员 | 补跑宽限期(1~168 小时) | +| `page_size` | `200` | 实例级 | 仅管理员 | 采集单页条数(20~1000) | +| `rewind_minutes` | `2` | 实例级 | 仅管理员 | 断点回退分钟数(0~120) | +| `drift_tolerance_minutes` | `5` | 实例级 | 仅管理员 | 云端时间漂移告警阈值(0~720) | +| `max_prompt` | `2048` | 实例级 | 仅管理员 | Prompt 入库截断长度,`0` = 不截断(0~20000) | +| `verify_days` | `0` | 实例级 | 仅管理员 | 采集后整日校验天数(0~90) | +| `timeout` | `30` | 实例级 | 仅管理员 | HTTP 超时秒数(5~300) | +| `ssl_verify` | `1` | 实例级 | 仅管理员 | 校验云端 HTTPS 证书 | + +**为什么调度与采集参数也放实例级**,而不是「个人级但只有管理员能写」: + +> 如果它们只写在管理员自己的 `user_id` 下,其它账号读取时会回落到 `DEFAULTS`, +> **管理员改的值对别人完全不生效** —— 那才是真正的坑。统一放实例级, +> 语义是「一台部署一套采集与调度策略」,读起来也简单。 +> +> `set_setting()` 还强制把全局键重定向到 `user_id=0`,从结构上消除 +> 「管理员改了只有自己生效」这类 bug。 + +**读配置有三级回落**:个人级 → 实例级 → `config.DEFAULTS`(`cookie` / `user_agent` 例外, +它们永不回落)。 + +**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒 —— 接口不能用来往 `settings` 表里塞任意键。 -环境变量(启动期,改了要重建容器): +> 另一个容易混的点:`slot:<时刻>` 这类**调度簿记键**仍是**个人级**的 —— +> 每个账号各自记「今天这个槽位跑过没」。**时刻本身是实例级,簿记是个人级**, +> 这两个千万别一起改。 -| 变量 | 说明 | +### 12.2 环境变量(启动期,改了要重建容器) + +| 变量 | 默认 | 说明 | +|---|---|---| +| `TZ` | 容器 `Asia/Shanghai` | 时区,影响所有日期口径与调度时刻 | +| `WB_BIND` | `0.0.0.0` | 宿主机绑定地址(仅 compose 用) | +| `WB_HOST` / `WB_PORT` | `0.0.0.0` / `8848` | 容器内监听地址 / 端口 | +| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | `/app/data` / `/app/logs` / `<数据目录>/usage.sqlite` | 路径覆盖 | +| `WB_COOKIE_SECURE` | `0` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) | +| `WB_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程 | +| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | `admin` / 空 | 首个管理员(**仅库为空时生效**) | +| `WB_IMPORT_CREDS` | `0` | 启动时从挂载的编辑器配置导入 Cookie | +| `WB_IMPORT_XLSX` | 空 | 启动时导入指定路径的官网 xlsx | +| `WB_IMAGE` | Gitea 地址 | 镜像名(仅 compose 用) | +| `WB_HOST_DATA_DIR` / `WB_HOST_LOG_DIR` | `./data` / `./logs` | 仅叠加 `hostdir` 时有效(**只建议 Linux**) | + +### 12.3 `manage.py` 子命令速查 + +| 命令 | 作用 | |---|---| -| `TZ` | 时区,影响所有日期口径 | -| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 | -| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 | -| `WB_COOKIE_SECURE` | `1` = 会话 Cookie 只走 HTTPS(纯 HTTP 部署必须留 `0`) | -| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 | -| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) | -| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 | +| `init [--user U] [--password P]` | 初始化 / 迁移数据库,创建首个管理员(幂等) | +| `serve [--host H] [--port P] [--debug] [--no-scheduler]` | 启动 Web(含进程内调度) | +| `collect [-u U]` | 执行一次增量采集(不传 `-u` 则逐个启用账号) | +| `migrate-csv [PATH] [-u U]` | 从 v1.0 的 CSV 导入(只读,可重复) | +| `import-xlsx PATH [-u U]` | 从官网导出的 xlsx 导入 | +| `fill-prompt [-u U]` | 补全缺失的 `User Prompt` | +| `export-csv [PATH] [-u U]` | 导出 CSV | +| `stats [-u U]` | 存档概况(先全库概览,再给指定账号明细) | +| `users` | 列出所有账号及数据量 / 凭证状态 | +| `import-creds [-u U]` | 从 VSCode / Cursor / Trae 设置导入 Cookie / UA | +| `passwd USER [PASSWORD] [--role admin\|user] [--activate]` | 重置 / 创建账号 | +| `status` | 各账号的调度与最近采集状态 | +| `vacuum` | 整理数据库(checkpoint + VACUUM) | + +Docker 部署下前面加 `docker compose exec portal`。 diff --git a/docs/FAQ.md b/docs/FAQ.md index e9e63a0..05d2161 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -74,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` @@ -104,7 +104,7 @@ 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 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。 @@ -350,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:数据库文件越来越大 @@ -372,10 +372,33 @@ docker compose exec portal python manage.py vacuum `明文凭证已加密: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 -i migrate +docker compose logs portal | grep -iE "迁移|migrat|收敛|promote" docker compose exec portal python manage.py users docker compose exec portal python manage.py stats ``` @@ -391,14 +414,25 @@ docker compose exec portal python manage.py stats > 迁移用 `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:想改采集的接口地址(走镜像/代理) @@ -415,13 +449,18 @@ docker compose exec portal python manage.py stats **五层,前两层必须跑绿**: ```bash -python tools/smoke.py # 离线回归 165 项(不需要起服务) +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 83 项 +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 <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录; diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index c52ccbd..c9c8c81 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -1,10 +1,13 @@ -# 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` 然后按输出的提示起服务即可。 @@ -12,17 +15,19 @@ **目录** - [一、这个系统是做什么的](#一这个系统是做什么的) -- [二、登录、注册与账号](#二登录注册与账号) -- [三、获取并填写 Cookie](#三获取并填写-cookie) -- [四、概览页:一眼看清家底](#四概览页一眼看清家底) -- [五、用量大屏:交互式分析](#五用量大屏交互式分析) -- [六、数据明细页:查、筛、导](#六数据明细页查筛导) -- [七、任务管理页:定时与补采](#七任务管理页定时与补采) -- [八、配置管理页:参数与维护](#八配置管理页参数与维护) -- [九、日志管理页:出问题先看这里](#九日志管理页出问题先看这里) -- [十、用户管理页(仅管理员)](#十用户管理页仅管理员) -- [十一、常见任务速查](#十一常见任务速查) -- [十二、常见问题](#十二常见问题) +- [二、注册与登录](#二注册与登录) +- [三、权限与数据边界](#三权限与数据边界) +- [四、配置你唯一的配置项:Cookie](#四配置你唯一的配置项cookie) +- [五、概览页:一眼看清家底](#五概览页一眼看清家底) +- [六、用量大屏:交互式分析](#六用量大屏交互式分析) +- [七、数据明细页:查、筛、导](#七数据明细页查筛导) +- [八、任务管理页:手动采集与只读的调度](#八任务管理页手动采集与只读的调度) +- [九、配置管理页](#九配置管理页) +- [十、个人中心](#十个人中心) +- [十一、管理员专属功能](#十一管理员专属功能) +- [十二、信息安全与隐私安全](#十二信息安全与隐私安全) +- [十三、常见任务速查](#十三常见任务速查) +- [十四、常见问题](#十四常见问题) --- @@ -36,17 +41,21 @@ 一次典型的日常是: ``` -每个账号按自己配的时刻自动采集(默认 09:00 / 17:00,你什么都不用做) +管理员把采集时刻统一设好(默认 09:00 / 17:00),服务端到点自动采集 ↓ -你想看看进度 → 打开「概览」看今天用了多少 -想深挖 → 打开「用量大屏」按模型/客户端/时段切 -要找某条记录 → 「数据明细」搜索 + 展开 Prompt -要拿给别人 → 「数据明细」→ 导出 CSV +你注册 / 登录 后做的第一件事:粘贴自己的 Cookie(否则采集会跳过你) + ↓ +想看进度 → 「概览」看今天用了多少 +想深挖 → 「用量大屏」按模型 / 客户端 / 时段切 +要找某条 → 「数据明细」搜索 + 展开 Prompt +要拿给别人 → 「数据明细」→ 导出 CSV ``` +**每个账号只看得见自己的数据**,这一点在下面第三章展开。 + --- -## 二、登录、注册与账号 +## 二、注册与登录 ### 2.1 登录 @@ -56,21 +65,21 @@ | 项目 | 说明 | |---|---| -| 默认账号 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) | +| 默认管理员 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) | | 登录保持 | 12 小时 | -| 验证码 | 默认**始终要求**,4 位,不区分大小写,5 分钟内有效、只能用一次 | +| 验证码 | 默认**始终要求**,4 位,不区分大小写,5 分钟内有效、**只能用一次** | | 失败限制 | 同一 IP、或同一用户名连续错 5 次,锁定 10 分钟 | | 退出 | 右上角「退出」(走 POST,防被恶意链接静默触发) | **关于验证码**: -- 图上只有数字与大写字母,并且**去掉了容易看错的 `0 O 1 I L`**; +- 图上只有数字与字母,并且**去掉了容易看错的 `0 O 1 I L`**; - 看不清就**点图片换一张**,不消耗任何额度; - 一张验证码**用完即废**:输错要换新的,登录用过之后也不能再拿去注册; -- 答案只存在服务器数据库里,浏览器拿到的只是一个随机编号——**在网页源码里搜不到答案**; +- **答案只存在服务器数据库里**,浏览器拿到的只是一个随机编号 —— 在网页源码里搜不到答案; - 被锁定期间,即使验证码填对也会被拒,等 10 分钟或换一个来源。 -> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。 +> ⚠️ 如果你是**管理员**且是首次部署:立刻改密码(`admin123` 等于没锁门)。 > 改法:「个人中心 → 修改登录密码」,或命令行 `python manage.py passwd admin 新密码`。 ### 2.2 自助注册 @@ -93,78 +102,99 @@ 2. **来源限额** —— 同一个 IP 每天最多注册 3 个账号(管理员可调); 3. **总开关** —— 管理员可以随时关闭注册入口。 +> ### 注册成功后你是什么权限 +> **注册出来的账号一律是「普通账号」**。普通账号能做的事只有一件配置相关的: +> **维护你自己的 Cookie 和 User-Agent**。 +> +> 定时任务的频率、采集参数、日志查看这些都属于管理员,你看到的是只读的。 +> 详细清单见下一章。 + > 注册成功后**不会**自动帮你配好采集。你要粘贴的是**你自己账号**的 Cookie, -> 见 [第三章](#三获取并填写-cookie)。在那之前,概览页只会提示「未配置凭证」。 - -### 2.3 个人中心 - -点右上角**你自己的名字**,进入个人中心(`/profile`)。 - -![个人中心](images/10-profile.png) - -| 区块 | 能做什么 | -|---|---| -| 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 | -| 修改资料 | 改显示名、邮箱(用户名只读) | -| 修改登录密码 | 需要原密码;改完当前会话仍然有效 | -| 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻 | - -> 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。 - -### 2.4 你的数据边界 - -这是多用户版最要紧的一条:**每个账号只看得到、也只影响自己的数据。** - -| 是「你的」 | 是「共用的」 | -|---|---| -| Cookie 与 User-Agent | 接口基址 / 接口路径 | -| 采集参数(分页、超时、截断…) | 是否开放自助注册、注册限额 | -| 调度开关与每日时刻 | 验证码策略与位数 | -| 用量记录、采集历史、导出的 CSV | 数据库文件本身 | - -两点值得记牢: - -- **管理员也看不到你的 Cookie 和用量明细。** 用户管理页只显示每个账号的记录条数与积分合计, - 点不进去看内容;Cookie 在页面上永远只回显「长度 + 结尾 4 位」。 -- **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号), - 所以每个账号都必须各自配一次 Cookie。 - -### 2.5 权限差别 - -| 能力 | 管理员 | 普通用户 | -|---|---|---| -| 概览 / 大屏 / 明细 / 任务 / 配置 / 日志 / 个人中心 | ✅ | ✅ | -| 改**自己**的采集参数、调度时刻、Cookie | ✅ | ✅ | -| 手动采集、按区间补采 | ✅(只动自己的数据) | ✅(只动自己的数据) | -| 导出 CSV | ✅(只有自己的) | ✅(只有自己的) | -| 整理数据库(VACUUM,整库操作) | ✅ | ❌ | -| 改**实例级**设置(接口地址、开放注册、验证码策略、注册限额) | ✅ | ❌(输入框置灰) | -| 应用日志尾部 | ✅ | ❌(接口 403,页面上该区块为空) | -| **用户管理**(建号 / 停用 / 删号 / 改权限) | ✅ | ❌(导航里不显示,直接访问返回 403) | - -> 给同事发普通账号即可,没必要共用管理员——管理员是能停用别人账号的角色。 +> 见 [第四章](#四配置你唯一的配置项cookie)。在那之前,概览页只会提示「未配置凭证」, +> 采集到点时会跳过你并记一条 `no_cookie`。 --- -## 三、获取并填写 Cookie +## 三、权限与数据边界 -**没有 Cookie,采集一定失败。** 这是每个账号**各自**要做一次的手工步骤。 +这是本系统最要紧的一章。**先看清自己能用什么,比急着点按钮有用。** -### 3.1 为什么要 Cookie,以及它怎么被保管 +### 3.1 两张身份 + +| | 管理员 | **普通账号(你注册后拿到的)** | +|---|---|---| +| 谁能拿到 | 首个部署账号,或由管理员授权 | 自助注册,或由管理员创建 | +| 配置权限 | 全部 | **只能维护本人的 Cookie / User-Agent** | +| 调度设置 | 可改(实例级,对所有人生效) | **只读**(看不到也改不了频率) | +| 日志查看 | 可看全实例日志与审计 | **无权限**(导航里不显示,直接访问返回 403) | +| 用户管理 | 可建号 / 停用 / 删号 / 改权限 | **无权限**(同上) | +| 看数据 | 只看自己的 | 只看自己的 | + +### 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 位」,形如 `1238 字符,结尾 …c0ffe`; +- **加密后入库**:落库前用 ChaCha20 + HMAC-SHA256 加密(主密钥在 `data/instance.json`, + 与数据库文件分开放),数据库被拷走也读不出明文; +- **永不回传明文**:页面与接口只回显「多少字符、结尾 4 位」; - **只属于你**:存在你的账号名下,别人(包括管理员)看不到、也拿不到; - **和 User-Agent 绑在一起**:两者必须取自**同一次浏览器请求**,否则云端会认为是另一个客户端。 > 「配置管理 → 我的云端凭证」里如果出现 **无法解密** 的红字提示,说明实例主密钥被换过 > (`data/instance.json` 被删或被替换),重新粘贴一次即可。详见 -> [十二、常见问题](#cookie-显示无法解密)。 +> [十四、常见问题](#cookie-显示无法解密)。 -### 3.2 拿 Cookie 的两种办法 +### 4.2 拿 Cookie 的两种办法 **办法 A:让程序自己从编辑器设置里读(最省事)** @@ -176,69 +206,78 @@ python manage.py import-creds -u alice # 想导给谁就写谁的用户名 ``` 它会去读编辑器 `settings.json` 里的 `codebuddyUsage.*` 字段,写进数据库。 -Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器)。 > ⚠️ 导入的是**运行这条命令的那台机器上、那个编辑器账号**的 Cookie。 -> 如果 A 同事的机器上跑这条命令去给 B 同事的账号导入,采到的就是 A 的数据—— -> 所以更稳的做法是让每个人自己登录网页、粘贴自己的 Cookie。 +> 如果 A 同事的机器上跑这条命令去给 B 同事的账号导入,采到的就是 A 的数据 —— +> 所以**更稳的做法是自己登录网页、粘贴自己的 Cookie**(就是下面的办法 B)。 +> 另外这条命令需要能访问部署机器,普通账号通常直接走办法 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 | -| `no_cookie`(采集被跳过) | 这个账号**还没配** Cookie | 按 3.2 填一份 | -| `cookie_broken` | 密文解不开(实例主密钥被换过) | 重新粘贴一次,见 [十二](#cookie-显示无法解密) | -| `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`。 @@ -253,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 筛选条件 | 条件 | 说明 | |---|---| @@ -283,152 +323,209 @@ 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」等于「关掉调度」。 -> 如果偶尔忘了开机,靠「启动补跑」把错过的时刻补回来。 +页面上会明确写着「**调度时刻与采集参数由管理员统一设定**,普通账号只读」。 +要是你需要改时刻,找管理员 —— 这是**整机一套**的策略。 -> **调度是按账号配置的**:你在这里改开关与时刻,只影响**你自己**的采集。 -> 到达时刻时,系统会逐个账号跑——没配 Cookie 的账号会被跳过并在日志里记一条 -> `no_cookie`,不会影响别人。别人也可以在别的时间点采,互不干扰。 +> 为什么这么设计:一台部署只有一个调度线程,所有账号在同一时刻被采集。 +> 让大家各定时刻只会让多个采集抢同一把写锁(SQLite 单写者),互相拖慢。 +> +> **但你随时可以手动采。** 需要「现在就采一次」时用下面的按钮,不受调度限制。 -### 7.2 手动采集 +### 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 | **你本人账号**的凭证,密文入库。留空保存 = 不修改;填一个 `-` = 清空已保存的 Cookie | | User-Agent | 与拿 Cookie 的浏览器保持一致更稳(**两者必须取自同一次请求**) | -保存后页面上只显示 `当前 Cookie:1238 字符,结尾 …c0ffe(2026-09-15 10:22 更新)`—— +保存后页面上只显示 `当前 Cookie:1238 字符,结尾 …c0ffe(2026-09-15 10:22 更新)` —— 页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过, 重新粘贴一次即可。 -### 8.2 采集参数 +> 凭证卡片上会有一行提示写着「**这一块是你唯一可以修改的配置**」—— +> 看到它就找对地方了。 -| 参数 | 默认 | 范围 | 说明 | +### 9.2 采集参数(只读) + +普通账号在这一块看到的是**只读表格**,输入框不可编辑,页面上还有一张 +「**为什么采集参数是只读的**」说明卡: + +| 参数 | 默认 | 说明 | +|---|---|---| +| `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 维护动作 + +| 按钮 | 作用 | 何时用 | 普通账号 | |---|---|---|---| -| `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 证书。**只在自签/企业代理场景才关** | - -> **写错的值会被当场拒绝**并提示原因,不会污染配置(历史版本会因为一个手滑的数字 -> 让采集整个跑不起来)。范围外的数、非数字都会在保存时被拦下。 - -> 上表里除 `api_base` / `api_path` 外,**其余都是「你自己的」配置**——改它只影响你这个账号的 -> 采集行为,不影响别人。`schedule_times` 这类调度项同理:每个人可以定自己的采集时刻。 - -### 8.3 维护动作 - -| 按钮 | 作用 | 何时用 | 谁能用 | -|---|---|---|---| -| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) | 所有人(只补自己的) | -| 导出我的 CSV | 导出**你自己的**全量数据到 `data/exports/` | 归档 / 交接 | 所有人 | -| 整理数据库 | `wal_checkpoint` + `VACUUM`(**整库操作**) | 删过数据后回收空间,或 WAL 文件偏大时 | **仅管理员** | +| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) | ✅ 只补自己的 | +| 导出我的 CSV | 导出**你自己的**全量数据到 `data/exports/` | 归档 / 交接 | ✅ | +| 整理数据库 | `wal_checkpoint` + `VACUUM`(**整库操作**) | 删过数据后回收空间 | ❌ 仅管理员 | 这些动作**耗时且会占用写权限**,所以有二次确认。执行期间不要重复点击。 -### 8.4 修改密码 +### 9.4 修改密码 填「当前密码 / 新密码 / 确认新密码」。改完当前会话仍然有效,其他会话需要重新登录。 (同样的表单在「个人中心」也有一份。) -### 8.5 实例级设置(仅管理员可见) +### 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 位。位数越多越难被自动识别,也越考验眼力 | +| 验证码位数 | 4 | 4 ~ 6 位 | -> **验证码策略怎么选**:默认的「始终要求」最安全;「仅连续失败后要求」对天天登录的人更友好, -> 但会给机器人留出 2 次免验证码的尝试机会。**「关闭」只有在前面已经有可信网关时才考虑。** +> **验证码策略怎么选**:默认的「始终要求」最安全;「仅连续失败后要求」对天天登录的人 +> 更友好,但会给机器人留出 2 次免验证码的尝试机会。**「关闭」只有在前面已经有 +> 可信网关时才考虑。** > -> 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删—— +> 验证码的答案只存在服务端 `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. 操作审计** -谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号、注册…… -可按**动作**筛选,支持翻页。 - -> 排错顺序建议:操作审计(有没有人动过) → 采集历史(采集本身成不成功) → 应用日志(程序有没有异常)。 - -> **多用户下你看到的范围**:「采集运行历史」与「操作审计」只有你**自己的**记录; -> 「应用日志尾部」是整机日志,**仅管理员可见**(普通账号看到的是空区块,接口返回 403)。 - ---- - -## 十、用户管理页(仅管理员) +### 11.4 用户管理页 ![用户管理页](images/06-users.png) @@ -441,7 +538,7 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示 | 改密码 | 给忘了密码的同事重置 | | 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 | -列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP**——但**看不到内容**: +列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP** —— 但**看不到内容**: 管理员能看到的只是「有多少」,看不到「是什么」,也看不到任何人的 Cookie。 内置四条护栏(前端置灰 + 后端再拦一次): @@ -451,37 +548,130 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示 3. **不能删除自己**; 4. **不能删掉最后一个启用的管理员**(防止系统变成没人能管)。 -页面底部是「**账号操作审计**」:最近 20 条账号相关动作,含**注册**与**登录失败**记录—— -想知道有没有人在撞你的密码,看这里。 +页面底部是「**账号操作审计**」:最近 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 → 保存 → 回补最近几天 | | 改我的显示名 / 邮箱 / 密码 | 右上角**点自己的名字** → 个人中心 | -| 看不清验证码 | **点验证码图片**换一张 | +| 看清我是什么权限 | 看顶栏右侧的标签:「管理员」还是「普通账号」 | +| 查我自己的采集有没有成功 | 任务管理 → 运行历史(只有我自己的) | +| 看清验证码 | **点验证码图片**换一张 | | 导出某段时间的数据给别人 | 数据明细 → 选日期 → 导出 CSV | | 导出我的全量存档 | 配置管理 → 维护动作 → 导出我的 CSV | | 找出最贵的请求 | 用量大屏 → 单笔 TOP | | 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 | -| 给同事开账号 | 用户管理 → 新建账号,权限选**普通**(或让同事自助注册) | -| 同事忘记密码 | 用户管理 → 该行「改密」 | -| 临时封掉某个账号 | 用户管理 → 该行「停用」(数据与 Cookie 保留) | -| 拒绝别人自助注册 | 配置管理 → 实例级设置 → 开放自助注册 → 关闭 | -| 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出 CSV | -| 关掉自动采集 | 任务管理 → 关「启用调度」 | -| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 | -| 系统变慢了 | 让**管理员**做「配置管理 → 整理数据库」;再不行看「十二」 | +| 改采集时刻 | ❌ 普通账号只读。找管理员改(或说明需求由他统一设) | +| 看日志排错 | ❌ 普通账号无权限。先用「任务管理 → 运行历史」,不够就找管理员 | +| 关掉自动采集 | ❌ 普通账号无权限。找管理员 | +| 系统变慢了 | 找**管理员**做「配置管理 → 整理数据库」;再不行看下一章 | +| 把数据备份走 | 找运维按 [部署指南第八节](DEPLOYMENT.md#八备份与恢复) 备份,或自己导出 CSV | +| 给同事开账号 | 管理员:用户管理 → 新建账号,权限选**普通**(或让同事自助注册) | +| 同事忘记密码 | 管理员:用户管理 → 该行「改密」 | +| 临时封掉某个账号 | 管理员:用户管理 → 该行「停用」(数据与 Cookie 保留) | --- -## 十二、常见问题 +## 十四、常见问题 ### 验证码看不清 @@ -492,9 +682,9 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示 三种可能,按顺序排查: -1. **这张图已经用过了** —— 验证码是**一次性**的,输错一次、或登录成功之后,它立刻作废, +1. **这张图已经用过了** —— 验证码是**一次性**的,输错一次、或登录成功之后它立刻作废, 必须点图片重新取一张; -2. **超过了 5 分钟** —— 有效期只有 5 分钟,慢慢来的话会过期,换一张即可; +2. **超过了 5 分钟** —— 有效期只有 5 分钟,换一张即可; 3. **跨了页面** —— 登录页取到的图不能拿去注册页用(两边的验证码是分开的)。 > 另外:如果这个来源已被锁定(连续失败 5 次),即使验证码正确也会被拒;等 10 分钟再试。 @@ -509,35 +699,66 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示 同一个 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_broken`。 -**怎么办**:重新粘贴一次这个账号的 Cookie 即可,历史数据不受影响。 -**怎么避免**:`data/instance.json` 里存着会话签名密钥和加密主密钥——备份数据库时 -**把它一起备份**,并且不要在容器之间混用。 +**怎么办**:**重新粘贴一次你自己的 Cookie 即可**,历史数据不受影响。 +(这个操作只有你本人能做 —— 别人看不到你的 Cookie,也就没法替你恢复。) + +**怎么避免**:这是运维的事 —— 备份数据库时要**把 `instance.json` 一起备份**, +并且不要在容器之间混用。 ### 我能不能看别人的用量 不能,管理员也不能。「用户管理」页只显示每个账号的记录条数与积分合计,看不到内容。 这是设计如此:Cookie 是账号级凭证,让它跨账号可见等于把别人的账号交出去。 -如果确实需要合并统计,正确做法是让每个人各自导出 CSV,再在外部合并。 +如果团队确实需要合并统计,正确做法是**每个人各自导出 CSV,再在外部合并**。 ### 采集报 `cookie_expired` / `unauthorized` -Cookie 过期。重新按 [3.2](#32-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。 -Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了**——建议用 -「办法 B」从已登录的浏览器里复制时,勾选「保持登录」。 +Cookie 过期。重新按 [4.2](#42-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。 +Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了** —— 建议从已登录的 +浏览器里复制时,勾选「保持登录」。 ### 采集成功但「新增 0 条」 大概率是**正常的**:断点续采意味着没有新请求时确实没有新增。 -看「日志管理」里那一次的 `抓取` 条数: +看运行历史里那一次的 `抓取` 条数: + - `抓取 > 0,新增 = 0` → 云端返回的都是库里已存在的,正常; - `抓取 = 0` → 该时段云端确实没有记录。 @@ -545,11 +766,11 @@ Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失 所有日期都按**部署机器的本地时区**(容器里由 `TZ` 决定,默认 `Asia/Shanghai`)计算。 如果服务器时区不是东八区,跨日的数据会落到相邻日期上。 -Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。 +这属于部署问题 —— 找管理员确认 `TZ=Asia/Shanghai`(Docker)或系统时区(裸机)。 ### 导出的 CSV 在 Excel 里中文乱码 -不会——导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件, +不会 —— 导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件, 而不是手工用记事本另存过的版本。 ### 提示「采集正在进行中」 @@ -561,19 +782,19 @@ Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。 1. 强制刷新(`Ctrl+F5`)清掉旧缓存; 2. 检查浏览器控制台有没有资源 404; -3. 到「日志管理 → 应用日志」看有没有异常栈。 +3. 找管理员看应用日志有没有异常栈(你这边看不到日志页)。 ### 关掉浏览器后调度还在跑吗 -在的。调度在**服务端进程**里,和浏览器无关。要停就去「任务管理」关调度开关, -或停掉服务。 +在的。调度在**服务端进程**里,和浏览器无关。它是整机一套的, +所以你关不关浏览器都不影响它 —— 也正因为如此,它不归你管。 ### 忘记密码 -**你自己的密码忘了**:网页上没法自助重置(没有邮件通道),找管理员在 +**你自己忘了**:网页上没法自助重置(没有邮件通道),找管理员在 「用户管理 → 该行『改密』」给你设一个新的。 -**管理员密码忘了**(或者被自己停用了),到部署机器上执行: +**管理员忘了**(或者被自己停用了),到部署机器上执行: ```bash python manage.py passwd admin 新密码 # 裸机 @@ -596,14 +817,14 @@ python manage.py users 正本是 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`, -且只有一份能安全写——所以**不要**横向扩展这个服务。 +且只有一份能安全写 —— 所以**不要**横向扩展这个服务。 --- diff --git a/docs/images/00-login.png b/docs/images/00-login.png index acb0f53..4a7ddf9 100644 Binary files a/docs/images/00-login.png and b/docs/images/00-login.png differ diff --git a/docs/images/01-overview.png b/docs/images/01-overview.png index 7d21d69..9590448 100644 Binary files a/docs/images/01-overview.png and b/docs/images/01-overview.png differ diff --git a/docs/images/02-records.png b/docs/images/02-records.png index a9fffe7..8c8a956 100644 Binary files a/docs/images/02-records.png and b/docs/images/02-records.png differ diff --git a/docs/images/03-tasks.png b/docs/images/03-tasks.png index 45bb4ab..90130ed 100644 Binary files a/docs/images/03-tasks.png and b/docs/images/03-tasks.png differ diff --git a/docs/images/03b-tasks-user.png b/docs/images/03b-tasks-user.png new file mode 100644 index 0000000..866a68a Binary files /dev/null and b/docs/images/03b-tasks-user.png differ diff --git a/docs/images/04-config.png b/docs/images/04-config.png index 9d51860..66f02fa 100644 Binary files a/docs/images/04-config.png and b/docs/images/04-config.png differ diff --git a/docs/images/04b-config-user.png b/docs/images/04b-config-user.png new file mode 100644 index 0000000..886161c Binary files /dev/null and b/docs/images/04b-config-user.png differ diff --git a/docs/images/05-logs.png b/docs/images/05-logs.png index 2080f1b..290e4d2 100644 Binary files a/docs/images/05-logs.png and b/docs/images/05-logs.png differ diff --git a/docs/images/06-users.png b/docs/images/06-users.png index 8bdca6e..d1ab74f 100644 Binary files a/docs/images/06-users.png and b/docs/images/06-users.png differ diff --git a/docs/images/07-dashboard.png b/docs/images/07-dashboard.png index 975346b..d2d9faa 100644 Binary files a/docs/images/07-dashboard.png and b/docs/images/07-dashboard.png differ diff --git a/docs/images/08-dashboard-interact.png b/docs/images/08-dashboard-interact.png index 2b6959e..10bea1b 100644 Binary files a/docs/images/08-dashboard-interact.png and b/docs/images/08-dashboard-interact.png differ diff --git a/docs/images/09-register.png b/docs/images/09-register.png index 15d47e4..9334fc4 100644 Binary files a/docs/images/09-register.png and b/docs/images/09-register.png differ diff --git a/docs/images/10-profile.png b/docs/images/10-profile.png index c1f5a67..da79431 100644 Binary files a/docs/images/10-profile.png and b/docs/images/10-profile.png differ diff --git a/tools/check_docs.py b/tools/check_docs.py new file mode 100644 index 0000000..28d7236 --- /dev/null +++ b/tools/check_docs.py @@ -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 锚点: 或 +HTML_ANCHOR_RE = re.compile(r"\…` 是**良好实践**,不是泄漏。 +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()) diff --git a/tools/check_live.py b/tools/check_live.py index 3faa462..0346bf2 100644 --- a/tools/check_live.py +++ b/tools/check_live.py @@ -5,18 +5,23 @@ """端到端验收:对**运行中的**服务发真实 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 @@ -313,6 +318,10 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None: 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....,恰好用正则判定, # 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车) @@ -417,6 +426,74 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None: "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="对运行中的用量门户做端到端验收") ap.add_argument("--base", default="http://127.0.0.1:8848", help="服务地址") @@ -429,12 +506,25 @@ def main() -> int: "验证码策略为 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() 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)) diff --git a/tools/demo_data.py b/tools/demo_data.py index 5319a81..59462ce 100644 --- a/tools/demo_data.py +++ b/tools/demo_data.py @@ -184,9 +184,10 @@ def build(out_dir: str, days: int, seed: int, admin_password: str, # 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。 db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid) db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid) - # 两个账号各有一套调度时刻,界面上能看出「每人可改自己的」 - db.set_setting(conn, "schedule_times", "09:00,17:00", admin_uid) - db.set_setting(conn, "schedule_times", "08:30,20:00", 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) diff --git a/tools/shots.py b/tools/shots.py index 339e745..c7dd73d 100644 --- a/tools/shots.py +++ b/tools/shots.py @@ -49,6 +49,14 @@ CAPTURES = [ ("10-profile.png", "/profile", "个人中心", True), ] +# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态, +# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角, +# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。 +USER_CAPTURES = [ + ("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"), + ("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"), +] + def _find_browser() -> str | None: """找一个可用的 Chromium 可执行文件。 @@ -118,6 +126,9 @@ def main() -> int: 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 路径(默认 /data/usage.sqlite),用于取验证码答案") @@ -148,38 +159,42 @@ 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))) - def shoot(name, path, label): + def shoot(name, path, label, pg=None): + pg = pg or page errors.clear() - page.goto(a.base + path, wait_until="networkidle") - page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来 - page.screenshot(path=os.path.join(a.out, name), full_page=a.full) + 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]))) + 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 时自动解验证码) - page.goto(a.base + "/login", wait_until="networkidle") - page.fill('input[name=username]', a.user) - page.fill('input[name=password]', a.password) - if page.query_selector('input[name=captcha]'): - ans = _captcha_answer(ctx, db_path, "login") - if not ans: - print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path) - br.close() - return 1 - page.fill('input[name=captcha]', ans) - print("[ok] 已用库里的答案通过验证码") - page.click('button[type=submit]') - page.wait_for_load_state("networkidle") - if "/login" in page.url: + # 2) 以管理员登录(策略为 always 时自动解验证码) + if not login(page, ctx, a.user, a.password): print("[FAIL] 登录失败,后续截图无意义") br.close() return 1 @@ -208,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) diff --git a/tools/smoke.py b/tools/smoke.py index c020c8f..515d864 100644 --- a/tools/smoke.py +++ b/tools/smoke.py @@ -11,13 +11,18 @@ 因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。 覆盖内容: - 1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500 + 1. 全页面渲染(含 /users 与 /logs,均需管理员身份)——模板报错会直接暴露成 500 2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错) - 3. 历史缺陷防回归(见 4. 的 ①~⑭) - 4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界 + 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 里删掉。 不会改动任何用量数据。 @@ -107,6 +112,17 @@ def run() -> None: 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: @@ -241,15 +257,31 @@ def run() -> None: # User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的; # 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死: sentinel = "SMOKE-SENTINEL-UA/%s" % _rand() - saved_ua_inst = db.get_setting(conn, "user_agent", "", 0) + # 注意: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) - db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原 - chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst) + # 还原:**原本没有这一行就删掉**。实例级本不该存在凭证类键 + # (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) == @@ -362,13 +394,22 @@ def run() -> None: chk("⑦ totals(uid=0) 不含任何人的数据", query.totals(conn, 0)["records"] == 0) finally: - # 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去) + # 哨兵 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,)) - conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)") - conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)") + # 注意 `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 '\\'" @@ -376,7 +417,10 @@ def run() -> None: chk("3. 临时账号已清理", left == 0, "残留=%d" % left) # ---------------- 4. 普通账号的权限边界 ---------------- - print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==") + # 规则只有一条(config.writable_by):普通账号只能写本人的 cookie / user_agent, + # 其余(调度、采集参数、接口地址、注册策略)一律 400。页面隐藏 / disabled + # 只是「不给出误导性按钮」,真正的闸门在服务端,所以这里全部走真实请求。 + print("== 4. 非管理员:越权面必须全部关死 ==") viewer2 = "smoke_w_%s" % _rand() try: V2 = _mk_user(viewer2) @@ -386,18 +430,112 @@ def run() -> None: 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, html = page(cli, "/") - chk("概览导航不含「用户管理」", "用户管理" not in html) - chk("普通账号导航含「个人中心」入口", 'class="who"' in html) - for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"): - st, _ = page(cli, p) - chk("GET %-10s 非管理员=200" % p, st == 200, "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,)) - conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)") + # `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) + 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. 历史缺陷防回归 ---------------- diff --git a/workbuddy_portal/__init__.py b/workbuddy_portal/__init__.py index 9d7be09..dce1540 100644 --- a/workbuddy_portal/__init__.py +++ b/workbuddy_portal/__init__.py @@ -25,7 +25,7 @@ from flask import Flask, jsonify, render_template, request from . import config, db, security -__version__ = "1.2.0" +__version__ = "1.3.0" PROJECT_NAME = config.PROJECT_NAME diff --git a/workbuddy_portal/config.py b/workbuddy_portal/config.py index 8f227bf..93f5a21 100644 --- a/workbuddy_portal/config.py +++ b/workbuddy_portal/config.py @@ -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"), ] @@ -50,7 +53,12 @@ DEFAULTS = { "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", # 云端比本地早超过该值才告警 @@ -58,25 +66,55 @@ 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"), } -# 实例级配置:所有账号共用一份,只有管理员能改。 -# 其余键(采集参数 / 调度 / 凭证)都是个人级 —— 这正是「多用户」的核心: -# 每个人填自己的 Cookie、收自己的数据、定自己的采集时刻。 +# ---------------- 配置的作用域与写权限(改这里之前先读完整段)---------------- +# +# 三级回落:**个人级(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": "始终要求(默认,最安全)", diff --git a/workbuddy_portal/db.py b/workbuddy_portal/db.py index 351b536..95dd2c0 100644 --- a/workbuddy_portal/db.py +++ b/workbuddy_portal/db.py @@ -14,11 +14,17 @@ 多用户约定(改代码前务必先读): * `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 @@ -34,7 +40,8 @@ _initialized = False # 库结构版本。写在 PRAGMA user_version 里,用来判断是否需要迁移。 # 1 -> 单用户布局(settings 以 key 为主键,usage_records 以 request_id 为主键) # 2 -> 多用户布局(见 schema.sql 顶部说明) -DB_SCHEMA_VERSION = 2 +# 3 -> 调度与采集参数从个人级提升为实例级(普通用户只读;见 config.GLOBAL_KEYS) +DB_SCHEMA_VERSION = 3 # 这些键即使个人作用域没有值,也**不**回落到实例级 NO_FALLBACK_KEYS = {"cookie", "user_agent"} @@ -225,6 +232,38 @@ def _encrypt_legacy_secrets(conn): 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 @@ -244,9 +283,18 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non ts = now_str() # 默认配置灌在**实例级**(user_id=0)。个人作用域不预置行, # 读取时按「个人 -> 实例 -> DEFAULTS」三级回落,语义更清楚。 + # + # 凭证类键(cookie / user_agent)**刻意不灌**:它们是个人级的, + # 实例级存一份没有任何读取路径会用到(NO_FALLBACK_KEYS 挡住了回落), + # 只会让「读一下实例配置看看」的人拿到一个不该存在的凭证位。 for k, v in config.DEFAULTS.items(): + 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: @@ -255,6 +303,16 @@ def init_db(conn=None, create_admin=True, admin_user="admin", admin_password=Non " 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: diff --git a/workbuddy_portal/web/api.py b/workbuddy_portal/web/api.py index dcdb336..c98b77b 100644 --- a/workbuddy_portal/web/api.py +++ b/workbuddy_portal/web/api.py @@ -13,8 +13,12 @@ **多用户约定** 每个接口都只操作 `current_user()["id"]` 那份数据。查询函数要求显式传 uid, 所以这里漏传会直接 TypeError(而不是静默返回全量)。 -`/api/settings` 是唯一的例外:它会回传实例级配置供非管理员只读展示, -但**拒绝**非管理员写入实例级键。 + +写权限只有两条规则(`config.writable_by`,前后端与测试共用同一判断): + + * 普通用户**只能**写 `config.USER_EDITABLE_KEYS`(本人的 cookie / user_agent); + * 其余键(接口地址、注册策略、采集参数、调度时刻)只有管理员能写, + 任何越权写入都会被 `/api/settings` 拒绝并在审计里记一笔 `settings_rejected`。 """ import os from datetime import datetime @@ -76,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(), _uid())) + 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") @@ -174,7 +184,8 @@ def api_run(run_id): @login_required def api_status(): conn = db.get_db() - uid = _uid() + u = current_user() + uid = u["id"] sch = scheduler.get_scheduler() nxt = scheduler.next_run_at(conn, uid) last = conn.execute("SELECT * FROM collect_runs WHERE user_id=? ORDER BY id DESC LIMIT 1", @@ -184,6 +195,12 @@ def api_status(): 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, uid), @@ -344,7 +361,9 @@ def api_settings_get(): 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) @@ -363,9 +382,11 @@ def api_settings_post(): if k not in config.DEFAULTS: errors.append("未知配置项:%s" % k) continue - if config.is_global_key(k) and not u["is_admin"]: - # 实例级配置(接口基址、注册开关)只有管理员能改 —— - # 否则任意注册用户都能把大家的数据采集指向别的服务器 + if not config.writable_by(k, u["is_admin"]): + # 普通用户只能写**本人凭证**(cookie / user_agent)。其余键 —— + # 接口基址、注册策略、采集参数、调度时刻 —— 一律归管理员: + # 否则任意注册用户就能把大家的数据采集指向别的服务器, + # 或者把 page_size 调到 1000 去 hammer 云端接口。 denied.append(k) continue if k == "cookie": @@ -386,16 +407,24 @@ def api_settings_post(): db.set_setting(conn, k, val, uid) changed.append(k) if denied: - errors.append("以下为实例级配置,仅管理员可修改:%s" % "、".join(sorted(denied))) + errors.append("以下配置仅管理员可修改,本账号无法保存:%s。" + "普通账号可以维护的是本人凭证(Cookie / User-Agent)。" + % "、".join(sorted(denied))) if errors: 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 user_id=? AND key LIKE ?", - (uid, scheduler.SLOT_PREFIX + "%")) + # 改了每日时刻:清掉**不再存在的**槽位标记(所有账号一起清)。 + # 这里刻意不做「全清」——时刻是实例级的,全清会让全部账号在宽限期内 + # 一起重采一遍;只清失效槽位,既让新时刻立即生效,又不会造成批量重采。 + 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)}) diff --git a/workbuddy_portal/web/static/dashboard/index.html b/workbuddy_portal/web/static/dashboard/index.html index ed09979..5c45f3b 100644 --- a/workbuddy_portal/web/static/dashboard/index.html +++ b/workbuddy_portal/web/static/dashboard/index.html @@ -190,7 +190,9 @@
@@ -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(); diff --git a/workbuddy_portal/web/static/js/app.js b/workbuddy_portal/web/static/js/app.js index 093cc4e..279ed16 100644 --- a/workbuddy_portal/web/static/js/app.js +++ b/workbuddy_portal/web/static/js/app.js @@ -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 ""; } diff --git a/workbuddy_portal/web/templates/base.html b/workbuddy_portal/web/templates/base.html index 9bcc3f9..5455641 100644 --- a/workbuddy_portal/web/templates/base.html +++ b/workbuddy_portal/web/templates/base.html @@ -29,7 +29,11 @@ 数据明细 任务管理 配置管理 + {# 日志管理里是实例运行信息(数据库路径 / 账号名 / 来源 IP),仅管理员可见; + 服务端另有 @admin_required 兜底,这里隐藏只是不给出会 403 的死链。 #} + {% if cur.is_admin %} 日志管理 + {% endif %} {% if cur.is_admin %} 用户管理 {% endif %} @@ -37,7 +41,8 @@
{{ cur.display_name }} - {% if cur.is_admin %}管理员{% endif %} + {% if cur.is_admin %}管理员 + {% else %}普通账号{% endif %} {# 退出用 POST + CSRF:GET 型退出会被 这类请求静默触发 #}
@@ -60,8 +65,8 @@
- {{ project_title }} · {{ project_name }} · 多用户 · 采集在 Web 进程内按各账号配置的时刻执行
- 每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 data/usage.sqlite + {{ project_title }} · {{ project_name }} · 多用户 · 调度与采集参数由管理员统一设定
+ 每个账号只使用并只见得到自己的 Cookie 与用量数据;数据正本 data/usage.sqlite