feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
(漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落
凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文
注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效
页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停
修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量
验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
@@ -6,8 +6,9 @@
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
|
||||
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
|
||||
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页)。唯一例外是 `/captcha.png` |
|
||||
| **数据作用域** | **所有数据接口只返回当前登录账号的数据**。`user_id` 由服务端会话决定,**不接受客户端传入** —— 传 `?user_id=1` 会被忽略 |
|
||||
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400**。未登录的 POST 也会先被 CSRF 拦成 400(这比先鉴权更保守) |
|
||||
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
|
||||
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
|
||||
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
|
||||
@@ -27,6 +28,8 @@
|
||||
| `forbidden` | 403 | 已登录但权限不足 |
|
||||
| `not_found` | 404 | 接口或资源不存在 |
|
||||
| `busy` | 409 | 已有采集在跑(单写者约束) |
|
||||
| `no_cookie` | 409 | 本账号还没配 Cookie,无法采集 |
|
||||
| `cookie_broken` | 409 | Cookie 密文解不开(`cookie_key` 换过),需重新粘贴 |
|
||||
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
|
||||
| `api` | 502 | 云端接口异常 |
|
||||
| `internal` | 500 | 服务端异常 |
|
||||
@@ -235,29 +238,82 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|
||||
### GET `/api/settings`
|
||||
|
||||
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`slot:*`)不返回。
|
||||
读**当前登录账号的有效配置**。返回的是一个**扁平字典**:配置键 → 值。
|
||||
|
||||
> **凭证只回掩码**:`cookie` 这个键固定为空串,真正的状态在 `cookie_hint` / `cookie_broken`;
|
||||
> 内部簿记键(`slot:*`)根本不返回。
|
||||
> 下例中的数字与尾号都是合成示例数据,不是任何真实实例的值。
|
||||
|
||||
```json
|
||||
{
|
||||
"values": { "page_size": "200", "schedule_times": "09:00,17:00", ... },
|
||||
"cookie_hint": "4054 字符,…09db9a660825",
|
||||
"num_settings": { "page_size": [20, 1000, "条/页"], ... },
|
||||
"bool_settings": ["schedule_enabled", "catch_up"]
|
||||
"api_base": "https://www.workbuddy.cn",
|
||||
"api_path": "/billing/meter/get-user-request-usage",
|
||||
"page_size": "200",
|
||||
"schedule_times": "09:00,17:00",
|
||||
"ssl_verify": "1",
|
||||
"cookie": "",
|
||||
"cookie_hint": "92 字符,…c0ffee",
|
||||
"cookie_broken": false,
|
||||
"user_agent": "Mozilla/5.0 (...)",
|
||||
"_globalKeys": ["allow_register", "api_base", "api_path",
|
||||
"captcha_length", "captcha_policy", "register_max_per_ip"],
|
||||
"_canEditGlobal": true
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| 各配置键 | 有效值(`个人 → 实例 → DEFAULTS` 三级回落后的结果) |
|
||||
| `cookie` | **恒为空串** —— `db.get_settings()` 统一置空,明文只能经 `db.get_secret()` 取 |
|
||||
| `cookie_hint` | 「N 字符,…尾 4 位」;未配置时为空串 |
|
||||
| `cookie_broken` | `true` 表示密文解不开(`cookie_key` 换过),需重新粘贴 Cookie |
|
||||
| `_globalKeys` | 实例级键清单(所有账号共用一份,只有管理员能改) |
|
||||
| `_canEditGlobal` | 当前账号能否改 `_globalKeys` 里的键 |
|
||||
|
||||
### GET `/api/users`(管理员)
|
||||
|
||||
```json
|
||||
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
|
||||
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
|
||||
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员", "email": null,
|
||||
"is_admin": 1, "status": "active", "created_at": "...",
|
||||
"last_login_at": "...", "last_login_ip": "192.0.2.10", "login_count": 12 } ] }
|
||||
```
|
||||
|
||||
> **口令散列永不出现在响应里。**
|
||||
> **口令散列永不出现在响应里**(`_user_public()` 只挑安全字段)。
|
||||
|
||||
### GET `/logs/tail`
|
||||
### POST `/api/profile`
|
||||
|
||||
应用日志尾部。
|
||||
改**自己**的显示名与邮箱。
|
||||
|
||||
```json
|
||||
{ "display_name": "新显示名", "email": "me@example.com" }
|
||||
```
|
||||
|
||||
### POST `/api/captcha`
|
||||
|
||||
验证码机制的**自述**,便于排障时自检(不需要猜当前策略是什么)。
|
||||
|
||||
```json
|
||||
{ "policy": "always", "length": 4, "ttl_seconds": 300,
|
||||
"image_url": "/captcha.png",
|
||||
"note": "答案只存在服务端 captchas 表;一次性使用,校验后立即删除。" }
|
||||
```
|
||||
|
||||
### GET `/captcha.png`
|
||||
|
||||
**唯一不需要登录的接口**,返回一张 PNG 图形验证码。
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `purpose` | `login` | `login` \| `register`;其它值一律收敛为 `login`(不会 500) |
|
||||
|
||||
- 响应头带 `Cache-Control: no-store`(缓存旧图会导致「图没变但怎么输都错」);
|
||||
- 每来源 60 秒最多 40 张,超限返回 **429**;
|
||||
- **答案不会出现在响应里,也不会出现在任何页面源码或会话中** ——
|
||||
服务端只把随机 id 写进会话(`cap_login` / `cap_register`),答案留在 `captchas` 表。
|
||||
|
||||
### GET `/logs/tail`(**仅管理员**)
|
||||
|
||||
应用日志尾部。普通账号访问返回 **403**(账号自己的采集日志请用 `/api/runs`)。
|
||||
|
||||
| 参数 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
@@ -294,20 +350,25 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|---|---|
|
||||
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 944 条", ...}}` |
|
||||
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
|
||||
| 本账号未配 Cookie | `409 {"ok": false, "error": "no_cookie", "message": "..."}` |
|
||||
| Cookie 解不开 | `409 {"ok": false, "error": "cookie_broken", "message": "..."}` |
|
||||
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
|
||||
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
|
||||
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
|
||||
|
||||
> 采集只使用**当前账号自己的** Cookie(`collect.load_credentials(conn, uid)`),
|
||||
> 且会写一条带 `user_id` 的 `collect_runs`。多用户下不要并发触发采集(单写者约束)。
|
||||
|
||||
### POST `/api/maintenance/<action>`
|
||||
|
||||
把 CLI 维护动作搬到页面。
|
||||
|
||||
| `action` | 作用 |
|
||||
|---|---|
|
||||
| `fill-prompt` | 从云端回补缺失的 Prompt |
|
||||
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
|
||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
|
||||
| `recount` | 重新统计并返回当前条数 |
|
||||
| `action` | 作用 | 权限 |
|
||||
|---|---|---|
|
||||
| `fill-prompt` | 从云端回补缺失的 Prompt | 本人 |
|
||||
| `export-csv` | 全量导出 CSV 到 `data/exports/`(文件名带账号名) | 本人 |
|
||||
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` | **仅管理员** |
|
||||
| `recount` | 重新统计并返回当前条数 | 本人 |
|
||||
|
||||
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
|
||||
|
||||
@@ -321,12 +382,18 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
|---|---|
|
||||
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
|
||||
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
|
||||
| 非管理员改实例级键 | `400 {"ok": false, "error": "invalid", "errors": ["以下为实例级配置,仅管理员可修改:api_base"]}` |
|
||||
|
||||
要点:
|
||||
|
||||
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
|
||||
- `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` 审计。
|
||||
|
||||
### POST `/api/password`
|
||||
@@ -342,22 +409,36 @@ KPI + 环比。`from`/`to` 缺省时自动取全量区间。
|
||||
### POST `/api/users`(管理员)
|
||||
|
||||
```json
|
||||
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
|
||||
{ "username": "viewer", "display_name": "只读同事", "email": "viewer@example.com",
|
||||
"password": "…", "password2": "…", "is_admin": false }
|
||||
```
|
||||
|
||||
`is_admin` **默认 false**(多用户系统里「默认给管理员」是最常见的越权起点)。
|
||||
用户名 / 口令强度与自助注册同一套校验。
|
||||
|
||||
### POST `/api/users/<id>`(管理员)
|
||||
|
||||
```json
|
||||
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
|
||||
{ "display_name": "新名字", "email": "…", "is_admin": true,
|
||||
"status": "active", "password": "可选,重置密码" }
|
||||
```
|
||||
|
||||
**自锁护栏**(服务端强制,全部返回 400):
|
||||
|
||||
1. 不能取消自己的管理员身份;
|
||||
2. 不能停用自己的账号;
|
||||
3. 不能把最后一个**启用状态的**管理员降权或停用。
|
||||
|
||||
### POST `/api/users/<id>/delete`(管理员)
|
||||
|
||||
删除账号。**三重护栏**(服务端强制):
|
||||
删除账号。护栏:
|
||||
|
||||
1. 不能取消自己的管理员身份;
|
||||
2. 不能删除自己;
|
||||
3. 至少保留一个账号。
|
||||
1. 不能删除自己;
|
||||
2. 至少要保留一个账号;
|
||||
3. 不能删掉最后一个启用状态的管理员。
|
||||
|
||||
请求体可选 `{"keep_data": true}` 保留其用量数据;**默认连同数据与 Cookie 一起删除** ——
|
||||
留下孤儿数据既占空间,也会在有人重新注册同名账号时被看到(`user_id` 复用风险)。
|
||||
|
||||
违反返回 `400`。
|
||||
|
||||
|
||||
@@ -24,15 +24,17 @@ client.py 纯 urllib 调云端;读编辑器 settings.json 取凭证
|
||||
│
|
||||
▼
|
||||
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
|
||||
│ ▲
|
||||
│ │ scheduler.py 只是「到点调 collect.sync()」
|
||||
│ ▲ 所有函数都要求 uid
|
||||
│ │ scheduler.py 只是「到点调 collect.sync(conn, uid, ...)」
|
||||
▼
|
||||
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置
|
||||
crypto.py 凭证静态加密(手写 ChaCha20 + HMAC);captcha.py 出验证码图
|
||||
▼
|
||||
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置(主键 (user_id,key))
|
||||
│
|
||||
▼
|
||||
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
|
||||
│
|
||||
├──▶ web/views.py Jinja 后台(7 个页面)
|
||||
│ uid 是 WHERE 的第一个条件
|
||||
├──▶ web/views.py Jinja 后台(含 /login /register /profile + 6 个数据页)
|
||||
└──▶ web/api.py JSON(大屏 + 页面异步调用)
|
||||
```
|
||||
|
||||
@@ -40,38 +42,64 @@ query.py 全部聚合下推 SQL:daily / dims / top / records / summa
|
||||
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
|
||||
口径不可能不一致。
|
||||
|
||||
**关键点:没有「当前用户」这种隐式全局。** `uid` 必须由调用方一路显式传下去
|
||||
(见 [七、安全模型](#七安全模型)),所以「忘了过滤」在类型层面就写不出来。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型
|
||||
|
||||
```sql
|
||||
-- 多用户布局:所有按账号隔离的表都以 user_id 打头
|
||||
usage_records(
|
||||
request_id TEXT PRIMARY KEY, -- 云端请求 ID,去重靠它
|
||||
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
|
||||
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
|
||||
user_id INTEGER NOT NULL DEFAULT 0,
|
||||
request_id TEXT NOT NULL, -- 云端请求 ID,去重靠它
|
||||
ts TEXT NOT NULL, -- 本地时间戳 'YYYY-MM-DD HH:MM:SS'
|
||||
day TEXT NOT NULL, -- 派生字段:便于按天聚合与建索引
|
||||
hour INTEGER NOT NULL, -- 派生字段:0-23,供时段分布
|
||||
model TEXT, client TEXT,
|
||||
credits REAL NOT NULL,
|
||||
model TEXT NOT NULL DEFAULT '-', client TEXT NOT NULL DEFAULT '-',
|
||||
credits REAL NOT NULL DEFAULT 0,
|
||||
prompt TEXT, -- 可截断(max_prompt)
|
||||
first_seen TEXT, last_seen TEXT,
|
||||
cloud_ts TEXT -- 云端原始时间,用于漂移检测
|
||||
first_seen TEXT NOT NULL, last_seen TEXT NOT NULL,
|
||||
cloud_ts TEXT, -- 云端原始时间,用于漂移检测
|
||||
PRIMARY KEY (user_id, request_id) -- 复合主键:去重是「按账号」去重
|
||||
)
|
||||
|
||||
collect_runs(
|
||||
id INTEGER PRIMARY KEY, trigger TEXT, status TEXT,
|
||||
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL DEFAULT 0,
|
||||
trigger TEXT, status TEXT,
|
||||
started_at, finished_at, duration_ms,
|
||||
win_from, win_to, -- 本次扫描窗口
|
||||
fetched, added, dup, total, conflicts,
|
||||
exit_code, message, detail -- detail 存逐行日志原文
|
||||
)
|
||||
|
||||
settings(key PRIMARY KEY, value, updated_at) -- cookie / 调度 / 采集参数 / 与 slot:HH:MM 簿记
|
||||
users(id, username UNIQUE, password_hash, display_name, is_admin, created_at, last_login_at, login_count)
|
||||
audit_log(id, at, actor, action, detail, ip)
|
||||
settings(user_id INTEGER NOT NULL DEFAULT 0, key TEXT NOT NULL, value, updated_at,
|
||||
PRIMARY KEY (user_id, key))
|
||||
|
||||
users(id, username UNIQUE, password_hash, display_name, email,
|
||||
is_admin, status, register_ip, last_login_ip,
|
||||
created_at, last_login_at, login_count)
|
||||
|
||||
captchas(id TEXT PRIMARY KEY, answer TEXT, purpose TEXT,
|
||||
created_at, expires_at, used_at)
|
||||
|
||||
audit_log(id, user_id, at, actor, action, detail, ip)
|
||||
```
|
||||
|
||||
索引:`day`、`(day,hour)`、`(model,day)`、`(client,day)`、`credits DESC`、`ts`。
|
||||
覆盖了「按天」「按天+时段」「模型/客户端 × 天」「单笔 TOP」「时间排序」五类热点查询。
|
||||
**`user_id = 0` 的含义**:在 `settings` / `collect_runs` / `audit_log` 里表示
|
||||
**实例级**(所有账号共用,例如 `api_base`、系统迁移审计);在 `usage_records` 里
|
||||
是「尚未归属」的兜底值,正常不会出现。
|
||||
|
||||
索引全部以 `user_id` 打头,覆盖六类热点查询:
|
||||
`(user_id, day)`、`(user_id, day, hour)`、`(user_id, model, day)`、`(user_id, client, day)`、
|
||||
`(user_id, credits DESC)`、`(user_id, ts)`。
|
||||
|
||||
### 为什么要复合主键而不是「加一列 user_id」
|
||||
|
||||
`usage_records` 的主键从 `request_id` 变成 `(user_id, request_id)` 是**语义**变化:
|
||||
两个人可能各自命中同一个云端 `request_id`(同一台机器上的多个浏览器 profile 就会),
|
||||
单列主键会让后写入的人把前一个人的记录覆盖掉。去重必须按账号做。
|
||||
|
||||
### 为什么 `day`/`hour` 是冗余列
|
||||
|
||||
@@ -84,6 +112,27 @@ audit_log(id, at, actor, action, detail, ip)
|
||||
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
|
||||
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
|
||||
用户不会在配置页看到它们,也无法通过接口写入任意键。
|
||||
多用户下槽位标记是**按账号**的(`(user_id, 'slot:09:00')`),所以谁改了自己的时刻
|
||||
只影响自己。
|
||||
|
||||
### 升级路径:`PRAGMA user_version`
|
||||
|
||||
`db.init_db()` 用 `PRAGMA user_version` 判断库结构版本(0/1 = 单用户,2 = 多用户)。
|
||||
**主键变了的表不能 `ALTER`**,只能重建,顺序不能变:
|
||||
|
||||
```
|
||||
1. 删掉所有自建索引 ← 关键,见下
|
||||
2. ALTER TABLE ... RENAME TO _v1_xxx
|
||||
3. ALTER TABLE 加新列(只加列的表用这个,代价小得多)
|
||||
4. executescript(schema.sql) ← 此时列齐了,表与索引一次建全
|
||||
5. INSERT ... SELECT 回填(user_id) → DROP TABLE _v1_xxx
|
||||
6. 把历史明文凭证加密 + 写一条 schema_migrate 审计
|
||||
```
|
||||
|
||||
> **为什么第 1 步不能省**:`ALTER TABLE RENAME` 会把索引**一起带走且名字仍被占用**,
|
||||
> 于是随后的 `CREATE INDEX IF NOT EXISTS` 被静默跳过 —— 新表一个索引都没有,
|
||||
> 功能看起来完全正常,查询却慢几百倍。这是最阴的一类迁移 bug。
|
||||
> 第 3 步放在第 4 步之前,则是为了让引用新列的索引一次就建成功。
|
||||
|
||||
---
|
||||
|
||||
@@ -240,19 +289,85 @@ records: id c(credits) m(model) cl(client) t(ts) px(prompt)
|
||||
| 密码存储 | `pbkdf2:sha256:200000`(Werkzeug 实现) |
|
||||
| 会话 | Flask 签名 cookie `workbuddy_portal_sid`,HttpOnly + SameSite=Lax,12 小时 |
|
||||
| 密钥持久化 | `data/instance.json` 的 `secret_key`,重启不踢人 |
|
||||
| 失败限速 | 同 IP 连续 5 次失败锁定 10 分钟;计数表有上限(4096 个 IP)与 TTL(1 小时) |
|
||||
| 失败限速 | **IP 与用户名双维度**各连续 5 次失败锁定 10 分钟;计数表有上限(8192 key)与 TTL(1 小时) |
|
||||
| 验证码 | 策略 `always`(默认)/ `adaptive` / `off`;**先验码再验密** |
|
||||
| 注册 | 开关 `allow_register` + 同 IP 每日配额 `register_max_per_ip` + 强制验证码 |
|
||||
| 停用即失效 | `current_user()` 每请求回查 `users.status`(缓存在 `flask.g`),不等会话过期 |
|
||||
| 会话固定防护 | `login_session()` 先 `session.clear()`,顺带换掉 CSRF token 与验证码 id |
|
||||
|
||||
### 授权
|
||||
### 授权与数据隔离
|
||||
|
||||
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
|
||||
`@admin_required`(403)两层。`/users` 与 `/api/users*` 全部要管理员。
|
||||
`@admin_required`(403)两层。`/users`、`/api/users*`、`/logs/tail`、`vacuum` 要管理员。
|
||||
|
||||
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能删自己、至少留一个账号。
|
||||
**多租户隔离靠「显式传参」而不是「隐式全局」**,这是本节最重要的一条设计:
|
||||
|
||||
```python
|
||||
# 每个公开函数都把 uid 放在 conn 之后的第一个位置,且**不给默认值**
|
||||
def daily(conn, uid, frm=None, to=None, with_maps=True): ...
|
||||
def totals(conn, uid, frm=None, to=None): ...
|
||||
collect.sync(conn, uid, trigger="manual", ...)
|
||||
scheduler.slots(conn, uid=0)
|
||||
```
|
||||
|
||||
为什么不给默认值?因为一旦写成 `uid=0`,忘记传参就会**静默返回实例级(≈全量)数据**——
|
||||
这种越权不会报错、不会进日志,只会在某天被人发现。给成必填参数后,漏传是 `TypeError`,
|
||||
在第一次跑测试时就炸掉。
|
||||
|
||||
配套的几条:
|
||||
|
||||
| 项 | 做法 |
|
||||
|---|---|
|
||||
| 单条读取也过滤 | `/api/records/<id>`、`/api/runs/<id>` 的 `WHERE` 都带 `user_id` |
|
||||
| 配置作用域 | 三级回落 `个人 → 实例 → DEFAULTS`;`GLOBAL_KEYS` 只有管理员能改 |
|
||||
| **凭证不回落** | `NO_FALLBACK_KEYS = {cookie, user_agent}` 跳过实例级回落 —— 否则新账号会「继承」管理员的 Cookie,这是最严重的串号越权 |
|
||||
| 导出隔离 | CSV 文件名带账号名(多用户下同目录同名会互相覆盖) |
|
||||
| 审计归属 | `audit_log` / `collect_runs` 都带 `user_id`;`/api/audit` 普通账号只看自己 |
|
||||
|
||||
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能停用自己、
|
||||
不能删自己、至少留一个账号、至少留一个**启用状态的**管理员。
|
||||
|
||||
### 凭证加密与验证码
|
||||
|
||||
这两件事都要求「零第三方依赖」(`requirements.txt` 只有 Flask / waitress / openpyxl),
|
||||
所以都是手写标准库实现。
|
||||
|
||||
**凭证加密(`crypto.py`)** —— 手写 ChaCha20(RFC 8439 §2.3 的块函数)+ HMAC-SHA256
|
||||
**encrypt-then-MAC**,所以不存在「先解密再验签」的填充预言类问题:
|
||||
|
||||
```
|
||||
密文 = "v1." + b64(salt) + "." + b64(nonce) + "." + b64(ciphertext) + "." + b64(tag)
|
||||
子密钥 = HMAC-SHA256(master, salt, "aead-key") / ("aead-mac") ← 密钥分离
|
||||
```
|
||||
|
||||
实现上的三个决定:
|
||||
|
||||
1. **`decrypt()` 失败抛异常,绝不返回原值**。返回原值看似「容错」,
|
||||
实际是把「密钥不匹配」伪装成「Cookie 是个奇怪字符串」,然后把这段垃圾发给云端。
|
||||
现在会明确抛 `db.SecretUnreadable`,页面提示「密文解不开,请重新粘贴」。
|
||||
2. **非 `v1.` 前缀原样返回** —— 专门用来兼容单用户时代存下来的明文;
|
||||
下次写入时自动升级为密文。升级迁移还会主动扫一遍并就地加密。
|
||||
3. **`get_settings()` 把加密键置空**,明文的唯一出口是 `db.get_secret()`。
|
||||
这比「记得别回传 cookie」可靠:写新接口的人即使 `**settings` 一把梭也带不出凭证。
|
||||
|
||||
**图形验证码(`captcha.py`)** —— 手写 PNG 编码器(zlib 压缩 IDAT)+ 5×7 点阵字模
|
||||
+ Bresenham 干扰线 + 逐字符抖动 + 噪点:
|
||||
|
||||
| 决定 | 原因 |
|
||||
|---|---|
|
||||
| 出 PNG 而不是 SVG | SVG 是文本,答案会**明文出现在页面源码里**,等于把答案发给机器人 |
|
||||
| 不用第三方 captcha/Pillow | 保持零第三方依赖;图像只由点阵矩形构成,没必要引入整个图像栈 |
|
||||
| 答案不进会话 | Flask 会话是「签名 + base64,**不加密**」的(客户端可解码读明文),放答案等于送答案。只写一个随机 id |
|
||||
| 先删后判 | `verify()` 先 `DELETE` 再比对,避免并发下同一张图被用两次 |
|
||||
| 按 `purpose` 隔离 | 拿注册的题去登录校验必然失败 |
|
||||
| 出图限速 | 60 秒 40 张 —— 出图要做点阵渲染 + zlib 压缩,不设限就是一条廉价的 CPU/带宽放大路径 |
|
||||
| 登录先验码 | 否则攻击者能拿「密码对不对」当信号,在解验证码之前就把字典跑完 |
|
||||
|
||||
### CSRF
|
||||
|
||||
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
|
||||
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
|
||||
未登录的 `POST` 也会先被 CSRF 拦成 400(先拦比先鉴权更保守)。
|
||||
|
||||
### 开放重定向
|
||||
|
||||
@@ -298,6 +413,23 @@ page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认
|
||||
|
||||
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
|
||||
|
||||
### 配置的作用域:三级回落与一个例外
|
||||
|
||||
```
|
||||
个人(user_id=n) ──没有──▶ 实例(user_id=0) ──没有──▶ config.DEFAULTS
|
||||
▲
|
||||
└── NO_FALLBACK_KEYS(cookie / user_agent)到此为止,不回落到实例级
|
||||
```
|
||||
|
||||
`GLOBAL_KEYS`(`api_base` / `api_path` / `allow_register` / `register_max_per_ip` /
|
||||
`captcha_policy` / `captcha_length`)在读写两侧都被强制折算到 `user_id = 0`,
|
||||
所以它们天然只有一份,非管理员改不了。
|
||||
|
||||
有个**容易误判**的细节:`NO_FALLBACK_KEYS` 只拦住「实例级那一行」,不拦 `DEFAULTS`。
|
||||
所以一个全新账号读 `user_agent` 拿到的是 `DEFAULTS` 里的**通用 Chrome UA**(非空),
|
||||
而不是空串 —— 这是刻意的(首次采集总得带个 UA)。测试断言要注意:
|
||||
正确的断言是「新账号的 UA ≠ 实例级那一份」,而不是「新账号的 UA 为空」。
|
||||
|
||||
---
|
||||
|
||||
## 九、已知坑与红线
|
||||
@@ -321,6 +453,20 @@ Flask 在 `full_dispatch_request()` 返回 `app_iter` **之后**就 pop 请求
|
||||
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
|
||||
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
|
||||
|
||||
### 母模板里的 `{% set %}` 会静默覆盖子模板的同名变量
|
||||
|
||||
`base.html` 顶层原本写 `{% set me = current_user() %}`,用来渲染右上角的用户名。
|
||||
但 `current_user()` 只回 `{id, username, display_name, is_admin}` 四个键 ——
|
||||
而 `profile.html` 自己也用 `me` 接视图传来的**完整用户行**。
|
||||
|
||||
结果:母模板的 `set` 把子模板的 `me` 顶掉了,`me.created_at` 取不到,
|
||||
个人中心渲染成「账号 admin · 注册于 · 最近登录 未登录」——**不报错、不告警**,
|
||||
页面看起来只是"少了个时间"。
|
||||
|
||||
**规则**:母模板里给全站用的局部变量要**起专门的名字**(现在叫 `cur`),
|
||||
不要复用子模板可能用到的键。`smoke.py` 里有 3 条断言盯着这件事
|
||||
(`注册于` 必须是真实日期、`最近登录` 不能是空占位、`base.html` 不得再出现 `set me = `)。
|
||||
|
||||
### Jinja 里避开 `dict` 的方法名
|
||||
|
||||
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
|
||||
@@ -367,8 +513,8 @@ GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
|
||||
| 层 | 手段 | 抓什么 |
|
||||
|---|---|---|
|
||||
| 1 | 独立聚合对账(直读 CSV 不走 `query.py`) | 口径错、少算。热力图要**逐格**比,历史上出过「同格覆盖少算 84%」 |
|
||||
| 2 | `tools/smoke.py`(99 项断言,离线) | 模板残留、历史缺陷防回归 ①~⑭、静态资源 404、class↔CSS 对账 |
|
||||
| 3 | `tools/check_live.py`(56 项断言,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF |
|
||||
| 2 | `tools/smoke.py`(**165 项断言**,离线) | 模板残留、历史缺陷防回归 ①~⑭、**多用户隔离 / 凭证保密 / 注册与验证码全链路**、静态资源 404、class↔CSS 对账 |
|
||||
| 3 | `tools/check_live.py`(**83 项断言**,真实 HTTP) | `test_client` 覆盖不到的:waitress、端口、cookie 往返、开放重定向、CSRF、验证码、安全响应头 |
|
||||
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
|
||||
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
|
||||
|
||||
@@ -379,4 +525,15 @@ python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
|
||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
|
||||
```
|
||||
|
||||
关于第 2 层在**多用户**下的两个约定:
|
||||
|
||||
1. `login(cli, uid)` 只注入 `uid` 不够「假装」成谁 —— `current_user()` 每请求回查 `users`
|
||||
表(为的是停用立即失效),所以 `uname` / `adm` 这些会话键改不了权限。
|
||||
要测非管理员行为,必须**真的**在库里有一个普通账号。
|
||||
`smoke.py` 会临时建一个(随机用户名,`finally` 里删掉),并在注册链路里临时再建一个。
|
||||
2. 「验证码答案没泄漏」这类断言要挑对判据:答案是 4 位随机大写串,
|
||||
直接在页面里搜它只能说明「这次没撞上」。更可靠的是**结构断言** ——
|
||||
会话里只有 id、库里才有答案、同 id 二次校验必失败、图必须由独立接口下发
|
||||
(页面里不出现 `data:image`)。
|
||||
|
||||
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
|
||||
|
||||
@@ -8,6 +8,84 @@
|
||||
|
||||
---
|
||||
|
||||
## [1.2.0] — 2026-09-15
|
||||
|
||||
**主题:多用户化 · Cookie 加密 · 开放注册与验证码**
|
||||
|
||||
从单用户版升级到多用户版。**数据不会丢**:`manage.py init` 会自动检测旧表结构并迁移
|
||||
(`PRAGMA user_version` 0 → 2),历史用量归到首个账号、明文 Cookie 就地加密,
|
||||
全程写一条 `schema_migrate` / `encrypt_secrets` 审计,且可重复执行。
|
||||
|
||||
### 新增
|
||||
|
||||
- **多用户与数据隔离**
|
||||
- `users` 表补齐 `email` / `status` / `register_ip` / `last_login_ip`;
|
||||
`settings` 主键改为 `(user_id, key)`,`usage_records` 改为 `(user_id, request_id)`,
|
||||
全部索引以 `user_id` 打头;`collect_runs` / `audit_log` 增加 `user_id`
|
||||
- `query.py` / `collect.py` / `scheduler.py` 全链路把 `uid` 作为 `conn` 之后的
|
||||
**第一个位置参数且无默认值** —— 漏传直接 `TypeError`,不会退化成「返回全量」
|
||||
- 配置三级回落:`个人 → 实例(user_id=0) → config.DEFAULTS`;
|
||||
新增 `NO_FALLBACK_KEYS = {cookie, user_agent}`,凭证**永不回落**(回落即串号越权)
|
||||
- `scheduler.tick()` 遍历启用账号逐个判断槽位;未配 Cookie 的账号自动跳过
|
||||
- 新增 `manage.py users` / `stats -u` / `status`(逐账号)/ 各子命令的 `-u/--user`
|
||||
- **Cookie 静态加密**(`workbuddy_portal/crypto.py`,约 190 行,**零第三方依赖**)
|
||||
- 手写 ChaCha20 块函数(RFC 8439 §2.3)+ HMAC-SHA256 **encrypt-then-MAC**,
|
||||
密文格式 `v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>`,已用官方测试向量逐字节验证
|
||||
- 主密钥 `cookie_key` 独立存放在 `data/instance.json`(与 `SECRET_KEY` 分开键位)
|
||||
- `db.get_secret()` 是取明文的**唯一**通道;`get_settings()` 把加密键一律置空;
|
||||
`db.secret_state()` 只回 `{set, chars, tail, broken}`,绝不含明文
|
||||
- `decrypt()` 对非 `v1.` 前缀原样返回(兼容历史明文,下次写入自动升级),
|
||||
校验失败**抛异常**而不是「失败就返回原值」;升级时自动把历史明文加密
|
||||
- **开放注册**:`/register` 页 + `POST /api/users`(管理员);开关 `allow_register`、
|
||||
同 IP 每日配额 `register_max_per_ip`;用户名/密码强度校验(保留字黑名单、≥8 位且两类字符)
|
||||
- **图形验证码**(`workbuddy_portal/captcha.py`,约 250 行,零第三方依赖)
|
||||
- **手写 PNG 编码器**(zlib 压缩 IDAT)+ 5×7 点阵字模 + Bresenham 干扰线与噪点。
|
||||
刻意不用 SVG —— SVG 是文本,答案会明文出现在页面源码里
|
||||
- 答案只写服务端 `captchas` 表;会话里仅存随机 id;**一次性、5 分钟过期、按用途隔离**
|
||||
- 策略 `captcha_policy`:`always`(默认)/ `adaptive`(同来源失败 2 次后要求)/ `off`
|
||||
- 登录**先验验证码再比口令**(否则攻击者能拿「密码对不对」当信号提前跑完字典)
|
||||
- **安全加固**
|
||||
- 失败限速改为 **IP + 用户名双维度**,任一超限即锁;新增验证码出图限速(60s/40 张)
|
||||
- `current_user()` 每请求回查 `users.status` ⇒ 停用账号**立即**失效,不必等会话过期
|
||||
- 安全响应头:CSP / `X-Frame-Options` / `nosniff` / `Referrer-Policy` / COOP;
|
||||
`/api/*` 与 `/captcha*` 带 `no-store`
|
||||
- 会话 cookie 显式 `HttpOnly` + `SameSite=Lax` + `Path=/`;`WB_COOKIE_SECURE=1` 可开 Secure
|
||||
- `/logs/tail` 改为**仅管理员**;管理员不能停用/降权/删除自己
|
||||
- **页面**:新增 `/login` 验证码、`/register`、`/profile`(个人中心,点右上角用户名进入);
|
||||
`/config` 增加凭证状态与 `cookie_broken` 告警、实例级设置区;`/users` 增加邮箱/状态列与启停
|
||||
|
||||
### 变更
|
||||
|
||||
- 接口新增:`GET/POST /api/profile`、`POST /api/captcha`(机制自述)、
|
||||
`POST /api/users/<id>/delete`;`GET /api/settings` 回传 `_globalKeys` / `_canEditGlobal`
|
||||
- `/api/collect` 未配 Cookie 回 `409 no_cookie`,密文解不开回 `409 cookie_broken`
|
||||
(不再静默当成「未配置」)
|
||||
- `/records/export` 与 CLI `export-csv` 的默认文件名带账号名(多用户下同名会互相覆盖)
|
||||
- `WB_COOKIE` 环境变量兜底**已移除** —— 它会导致串号
|
||||
|
||||
### 修复
|
||||
|
||||
- `db.get_db()` 在流式响应里被复用导致 `Cannot operate on a closed database`
|
||||
(生成器内部改为自建连接)
|
||||
- `.dockerignore` 的 `__pycache__/` 只匹配上下文根目录,嵌套目录会被打进镜像
|
||||
- 注册成功提示与注册页说明里的 `**强调**` 字面量(HTML 不解析 Markdown)
|
||||
- **`base.html` 顶层的 `{% set me = current_user() %}` 会覆盖子模板传入的同名变量**
|
||||
—— 而 `current_user()` 只含 `id/username/display_name/is_admin`,于是个人中心把
|
||||
`me.created_at` 渲染成空(「注册于 ·」)。局部变量改名 `cur`,`smoke.py` 加 3 条防回归断言
|
||||
- `WB_COOKIE_SECURE` 没有写进 `docker-compose.yml` 的 `environment:`
|
||||
—— 在 `.env` 里设了也不生效,文档里的开关实际是哑的(已补上,并加进 `.env.example`)
|
||||
- 「配置管理 → 修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
|
||||
- 「用户管理」删除说明写「可勾选保留」,而页面只有确认框、必删数据,措辞改为与实际一致
|
||||
|
||||
### 升级提示
|
||||
|
||||
- 纯 HTTP 局域网部署**不要**设 `WB_COOKIE_SECURE=1`,否则浏览器不回传会话 cookie,
|
||||
表现为「刚登录完又被弹回登录页」
|
||||
- 迁移后请到「配置管理」确认 Cookie 状态;`secret_state.broken = true` 说明
|
||||
`data/instance.json` 里的 `cookie_key` 与写入时不一致,重新粘贴一次即可
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] — 2026-09-14
|
||||
|
||||
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
|
||||
|
||||
@@ -62,14 +62,17 @@ docker compose logs -f
|
||||
| `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)、`exports/` |
|
||||
| `/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`**(这不是随手选的):
|
||||
@@ -352,7 +355,7 @@ curl -s -u wangchuanli:TOKEN \
|
||||
|---|---|---|
|
||||
| `usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
|
||||
| `usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
|
||||
| `instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
|
||||
| `instance.json` | ★★★ | 含 `secret_key`(会话签名)**与 `cookie_key`(各账号 Cookie 的加密主密钥)**。丢了/被替换:所有人要重新登录,**且所有账号存的 Cookie 都会变成「无法解密」,需要各自重填** |
|
||||
| `exports/*.csv` | ★ | 导出快照,可再生 |
|
||||
| `workbuddy-portal_wb_logs` | ☆ | 排错用,可再生 |
|
||||
|
||||
@@ -465,10 +468,34 @@ sudo systemctl restart workbuddy-portal
|
||||
|
||||
### 升级前
|
||||
|
||||
1. **先备份**(见第六节)——`schema.sql` 用的是 `CREATE TABLE IF NOT EXISTS`,
|
||||
加表加索引是安全的,但改列需要手工迁移,所以备份是唯一保险。
|
||||
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 # 各账号条数与积分
|
||||
```
|
||||
|
||||
> 升级后请**确认 Cookie 能解密**:登录后打开「配置管理 → 我的云端凭证」,
|
||||
> 正常应显示「已配置 · N 字符,结尾 …xxxx」。若显示「无法解密」,说明 `instance.json`
|
||||
> 不匹配,重新粘贴一次即可。
|
||||
|
||||
---
|
||||
|
||||
## 八、日常巡检
|
||||
@@ -505,6 +532,23 @@ docker compose logs --tail=100
|
||||
| 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` |
|
||||
| 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 |
|
||||
|
||||
### 登录成功却立刻又跳回登录页
|
||||
|
||||
几乎一定是 `WB_COOKIE_SECURE` 被设成了 `1`,而你在用 **HTTP** 访问。
|
||||
|
||||
会话 Cookie 带 `Secure` 属性后,浏览器只在 HTTPS 下才回传;服务端每次都收不到会话,
|
||||
就判定「未登录」,再把你送回登录页。表现是「密码明明对,页面却停在登录页」,日志里
|
||||
看起来像在反复登录。
|
||||
|
||||
```bash
|
||||
# .env
|
||||
WB_COOKIE_SECURE=0
|
||||
docker compose up -d # 环境变量,必须 up -d,restart 不生效
|
||||
```
|
||||
|
||||
只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
|
||||
(另:如果站点前后端域名不同,还要看第四节的反代配置。)
|
||||
|
||||
### `exec format error` / `no such file or directory`(entrypoint)
|
||||
|
||||
`docker/entrypoint.sh` 被 CRLF 污染了。仓库里有 `.gitattributes` 强制 `*.sh` 为 LF;
|
||||
@@ -599,26 +643,35 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
|
||||
## 十、配置项速查
|
||||
|
||||
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
|
||||
也可以直接改表):
|
||||
也可以直接改表)。表的主键是 `(user_id, key)`:`user_id=0` 表示**实例级**(所有账号共用,
|
||||
仅管理员可改),其余是**个人级**(每个账号一份,互不可见)。
|
||||
|
||||
| 键 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| `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 证书 |
|
||||
| `api_base` / `api_path` | 官方地址 | 接口地址(走镜像/代理时改) |
|
||||
| `cookie` | 空 | 账号凭证(页面只回掩码) |
|
||||
| `user_agent` | Chrome UA | 与 Cookie 同源更稳 |
|
||||
| 键 | 默认 | 作用域 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `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 证书 |
|
||||
|
||||
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。
|
||||
**读配置时有三级回落**:个人级 → 实例级 → 代码里的 `DEFAULTS`。
|
||||
所以实例级的值只是「默认值」,任何账号都可以用自己的值覆盖它(`cookie` / `user_agent` 例外)。
|
||||
|
||||
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。未知键也会被拒——
|
||||
接口不能用来往 `settings` 表里塞任意键。
|
||||
|
||||
环境变量(启动期,改了要重建容器):
|
||||
|
||||
@@ -627,6 +680,7 @@ echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
|
||||
| `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` | 启动时自动导入 |
|
||||
|
||||
@@ -21,6 +21,22 @@ Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8
|
||||
2. 服务器防火墙有没有放行该端口;
|
||||
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
|
||||
|
||||
### Q:登录成功却立刻又跳回登录页(循环)
|
||||
|
||||
99% 是 `WB_COOKIE_SECURE` 被开成了 `1`,而你在用 **HTTP** 访问。
|
||||
|
||||
会话 Cookie 加了 `Secure` 属性后,浏览器**只在 HTTPS 下才回传它**——于是服务端每次收到
|
||||
请求都看不到会话,判定未登录,再把你送回登录页。日志里看起来是「一直在登录」。
|
||||
|
||||
```bash
|
||||
# .env 里改回 0(纯局域网 HTTP 部署的正确值),然后重建容器
|
||||
WB_COOKIE_SECURE=0
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
> 只有在前面真的挂了 HTTPS 反向代理、并且用域名访问时,才把它设为 `1`。
|
||||
> 注意 `TZ`、`WB_COOKIE_SECURE` 都是**环境变量**,`docker compose restart` 不生效,要 `up -d`。
|
||||
|
||||
### Q:容器 `unhealthy` 但 `Up`
|
||||
|
||||
```bash
|
||||
@@ -89,10 +105,26 @@ 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 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
|
||||
|
||||
### Q:采集被跳过,日志写 `no_cookie`
|
||||
|
||||
这个账号**还没配 Cookie**。多用户下每个账号要各自配一次——系统**不会**拿别人的 Cookie
|
||||
替你采集(那会把两个人的数据混在一起)。到「配置管理 → 我的云端凭证」粘贴一份即可。
|
||||
|
||||
### Q:日志写 `cookie_broken` / 页面显示「无法解密」
|
||||
|
||||
数据库里的 Cookie 密文,用当前实例主密钥解不开了。通常是 `data/instance.json`
|
||||
(存着 `cookie_key`)被删、被替换,或从别的机器拷了库过来。
|
||||
|
||||
**动作**:重新粘贴一次该账号的 Cookie,历史数据不受影响。
|
||||
**预防**:备份时把 `data/instance.json` 和数据一起备份,别在容器之间混用。
|
||||
|
||||
> 这是**静态加密的正确行为**——密钥换了就该解不开;如果它「解不开也照样能用」,
|
||||
> 那说明根本没加密。
|
||||
|
||||
### Q:采集成功但「新增 0 条」
|
||||
|
||||
大概率正常。看那一次的 `抓取` 条数:
|
||||
@@ -195,6 +227,39 @@ docker compose exec portal python -c "from workbuddy_portal import db; print(db.
|
||||
|
||||
## 五、账号与权限
|
||||
|
||||
### Q:怎么开放/关闭自助注册
|
||||
|
||||
「配置管理 → 实例级设置 → 开放自助注册」(**仅管理员可见**)。
|
||||
打开后登录页会出现「自助注册」链接,任何人填表即可建号;关掉后只能由管理员在
|
||||
「用户管理 → 新建账号」里建。默认是**开放**。
|
||||
|
||||
### Q:注册被拒 / 提示来源已达上限
|
||||
|
||||
同一个 IP 每天默认只能注册 3 个账号(`register_max_per_ip`,范围 1 ~ 50)。
|
||||
这条限制是防批量刷号用的;换个来源,或由管理员调大。
|
||||
|
||||
### Q:验证码一直不对
|
||||
|
||||
按这个顺序排查:
|
||||
|
||||
| 现象 | 原因 |
|
||||
|---|---|
|
||||
| 刚才还能用,第二次就错 | 验证码**一次性**,用一次即废;输错也要重新取图 |
|
||||
| 输得慢一点就错 | 有效期 **5 分钟**,过期即失效 |
|
||||
| 拿登录页的码去注册 | 两个 `purpose` 的验证码**互不通用** |
|
||||
| 明明对了还是被拒 | 该来源已被锁定(连续失败 5 次 → 锁 10 分钟) |
|
||||
|
||||
**动作**:点验证码图片换一张重来。想少费眼力,让管理员把「验证码位数」保持在 4 位。
|
||||
|
||||
> 验证码答案只存在服务端 `captchas` 表:下发到浏览器的是一个随机 `captcha_id`,
|
||||
> 校验后无论成败都立刻删除。所以**在网页源码里搜不到答案**,抓图也拿不到复用的码。
|
||||
|
||||
### Q:验证码能关掉吗
|
||||
|
||||
「配置管理 → 实例级设置 → 验证码策略」有 `always` / `adaptive` / `off` 三档。
|
||||
`adaptive` 只在同一来源**连续失败 2 次后**才要验证码,对天天登录的人更友好。
|
||||
`off` 会显著放大撞库与批量注册的风险,**只有在前面已有可信网关时才考虑**。
|
||||
|
||||
### Q:忘记管理员密码
|
||||
|
||||
```bash
|
||||
@@ -207,20 +272,58 @@ docker compose exec portal python manage.py passwd admin 新密码
|
||||
|
||||
不传新密码时会用默认的 `admin123`——**别这么干**。
|
||||
|
||||
### Q:怎么给同事开只读账号
|
||||
账号被停用了要顺便恢复启用,加 `--activate`:
|
||||
|
||||
「用户管理 → 新建账号」,**不要勾**「管理员」。
|
||||
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
|
||||
```bash
|
||||
python manage.py passwd admin 新密码 --activate
|
||||
```
|
||||
|
||||
想看现在有哪些账号、各自角色/状态/数据量/凭证状态:
|
||||
|
||||
```bash
|
||||
python manage.py users
|
||||
```
|
||||
|
||||
### Q:怎么给同事开账号
|
||||
|
||||
两种都行:
|
||||
|
||||
1. 让同事**自助注册**(需管理员开放注册);
|
||||
2. 「用户管理 → 新建账号」,权限选**普通**(默认就是普通,管理员要显式选)。
|
||||
|
||||
普通用户能看概览/大屏/明细/任务/配置/日志,能改**自己的**凭证与采集参数、能触发采集与导出;
|
||||
但看不到「用户管理」(访问 `/users` 返回 403),也改不了实例级设置(输入框置灰,接口也会拒)。
|
||||
|
||||
> 建完账号记得告诉同事:**要自己配一份自己的 Cookie**,否则采集不会跑(日志里是 `no_cookie`)。
|
||||
|
||||
### Q:管理员能看到别人的数据吗
|
||||
|
||||
**看不到。** 用户管理页只显示每个账号的记录条数与积分合计,点不进内容;
|
||||
任何页面上 Cookie 都只回显「长度 + 结尾 4 位」。采集也只用本人凭证。
|
||||
|
||||
所以「把两个人的数据合起来看」要各自导出 CSV 再到外部合并——这是刻意的边界,不是缺陷。
|
||||
|
||||
### Q:误操作了别人账号 / 删错了人
|
||||
|
||||
- **停用**是可逆的:数据与 Cookie 都保留,随时可以再启用;
|
||||
- **删除**不可逆:会连同该账号的用量数据与 Cookie 一起删。只能靠备份恢复
|
||||
(见 [六、运维 · 备份](#q备份怎么做最稳))。
|
||||
|
||||
每次账号操作都会写 `audit_log`,在「用户管理 → 账号操作审计」里能查到谁在什么时候动的。
|
||||
|
||||
### Q:不小心把自己降级 / 删掉自己了
|
||||
|
||||
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
|
||||
做不到。服务端有四条护栏:不能取消自己的管理员身份、不能停用自己、不能删除自己、
|
||||
不能删掉最后一个启用的管理员。
|
||||
|
||||
### Q:所有人被踢下线了
|
||||
|
||||
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
|
||||
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
|
||||
|
||||
> 同一个文件里还有 `cookie_key`(凭证加密主密钥),它变了会让**所有账号的 Cookie 都要重填**。
|
||||
> 备份数据库时务必把 `instance.json` 一起备份。
|
||||
|
||||
---
|
||||
|
||||
## 六、运维
|
||||
@@ -256,15 +359,38 @@ docker run --rm -v workbuddy-portal_wb_data:/data:ro -v "$PWD/backup":/backup \
|
||||
docker compose exec portal python manage.py vacuum
|
||||
```
|
||||
|
||||
或在「配置管理 → 维护动作 → 整理数据库」点一下。
|
||||
或在「配置管理 → 维护动作 → 整理数据库」点一下(**这个按钮仅管理员可见**,
|
||||
因为它动的是整库,不只你的数据)。
|
||||
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
|
||||
|
||||
### Q:从 1.1.0 升级到 1.2.0 要做什么
|
||||
|
||||
**手工动作:零。** 首次启动新版本时会自动迁移:
|
||||
|
||||
1. 老数据整体归到**第一个账号**(也就是原来的那个唯一账号);
|
||||
2. 原来明文存的 Cookie **就地加密**,日志里会记一条
|
||||
`明文凭证已加密:settings[uid=1].cookie`;
|
||||
3. 建 `captchas` 表、给各表补 `user_id` 列与索引。
|
||||
|
||||
看迁移结果:
|
||||
|
||||
```bash
|
||||
docker compose logs portal | grep -i migrate
|
||||
docker compose exec portal python manage.py users
|
||||
docker compose exec portal python manage.py stats
|
||||
```
|
||||
|
||||
**升级前务必备份**(含 `instance.json`):迁移会改主键与索引,虽然实现了回滚失败即中止,
|
||||
但备份永远是第一道保险。
|
||||
|
||||
### Q:升级会不会丢数据
|
||||
|
||||
不会。数据在 Docker 命名卷 `workbuddy-portal_wb_data` 里(对应容器内 `/app/data`),
|
||||
`docker compose up -d --build` 只重建容器,不碰卷。
|
||||
但**升级前依然要备份**(见上一条):`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
|
||||
加表加索引安全,**改列需要手工迁移**。
|
||||
但**升级前依然要备份**(见上一条)。
|
||||
|
||||
> 迁移用 `PRAGMA user_version` 记录版本,**幂等**:重复启动不会重复迁移。
|
||||
> 改列这种操作现在也由 `init_db()` 自动完成,不再是「需要手工迁移」。
|
||||
|
||||
### Q:日志在哪、怎么滚动
|
||||
|
||||
@@ -276,7 +402,9 @@ docker compose exec portal python manage.py vacuum
|
||||
|
||||
### Q:想改采集的接口地址(走镜像/代理)
|
||||
|
||||
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
|
||||
「配置管理 → 采集参数」里改 `api_base` 与 `api_path`。这两个是**实例级**键,
|
||||
只有管理员能改(普通账号看到的是置灰的输入框,接口层面也会拒绝)。
|
||||
改了之后记得同步确认 Cookie 是该域下的有效凭证。
|
||||
|
||||
---
|
||||
|
||||
@@ -287,14 +415,32 @@ docker compose exec portal python manage.py vacuum
|
||||
**五层,前两层必须跑绿**:
|
||||
|
||||
```bash
|
||||
python tools/smoke.py # 离线回归 99 项
|
||||
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
||||
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 56 项
|
||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
|
||||
python tools/smoke.py # 离线回归 165 项(不需要起服务)
|
||||
python tools/demo_data.py # 可选:造一份合成示例库
|
||||
python manage.py serve --port 8849 --no-scheduler # 另开终端
|
||||
python tools/check_live.py --base http://127.0.0.1:8849 # 真实 HTTP 83 项
|
||||
python tools/shots.py --base http://127.0.0.1:8849 --full # 界面截图 + JS 报错
|
||||
```
|
||||
|
||||
两个新增参数值得一提:
|
||||
|
||||
- `check_live.py --db <路径>`:让它直接读库里的验证码答案,从而**自动过验证码**登录;
|
||||
- `shots.py --db <路径>`:同上,截图脚本自动解开登录页与注册页。
|
||||
|
||||
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
|
||||
|
||||
### Q:改了多用户的代码,怎么确认没越权
|
||||
|
||||
三个低成本自查:
|
||||
|
||||
1. 在 `smoke.py` 的隔离小节里加一条断言 —— 调用 `query.*` 时**故意漏掉 `uid`**,
|
||||
期望它抛 `TypeError`(本项目把 `uid` 设计成「`conn` 之后的第一个位置参数、无默认值」,
|
||||
漏传就炸,不会静默返回全量);
|
||||
2. 临时建一个普通账号,把 `WB_DISABLE_SCHEDULER` 之类放一边,直接访问 `/users` 与
|
||||
`POST /api/settings` 写实例级键,都应该是 403;
|
||||
3. 写一个**哨兵值**(如 `SMOKE-SENTINEL-UA`)到实例级 `user_agent`,
|
||||
断言新账号读不到它——这一招能抓到「落回落到别人配置上」的越权。
|
||||
|
||||
### Q:`ModuleNotFoundError: No module named 'flask'`
|
||||
|
||||
选错解释器了。依赖装在项目的 venv 或托管环境里:
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# WorkBuddy Portal 用户使用手册
|
||||
|
||||
> 面向**使用者**(不是开发者)。读完这份就能独立完成日常操作:
|
||||
> 登录 → 看用量 → 配置采集 → 查明细 → 导数据 → 处理常见异常。
|
||||
> 注册 / 登录 → 配好自己的凭证 → 看用量 → 查明细 → 导数据 → 处理常见异常。
|
||||
|
||||
> **关于配图**:本文所有截图都用 `tools/demo_data.py` 生成的**合成示例数据**渲染
|
||||
> ——模型名统一是 `demo-*`,客户端为 `vscode`/`webconsole`/`sdk`,Prompt 是通用示例文本。
|
||||
> ——模型名统一是 `demo-*`,客户端为 `vscode`/`webconsole`/`sdk`,Prompt 是通用示例文本,
|
||||
> Cookie 是**假串**(`wb_demo_session=…`),账号是 `admin` 与 `demo` 两个。
|
||||
> 所以你可以照着重现出几乎一样的界面,也不必担心文档里夹带真实账号信息。
|
||||
> 想自己搭一份这样的环境:`python tools/demo_data.py` 然后按输出的提示起服务即可。
|
||||
|
||||
**目录**
|
||||
|
||||
- [一、这个系统是做什么的](#一这个系统是做什么的)
|
||||
- [二、登录与账号](#二登录与账号)
|
||||
- [二、登录、注册与账号](#二登录注册与账号)
|
||||
- [三、获取并填写 Cookie](#三获取并填写-cookie)
|
||||
- [四、概览页:一眼看清家底](#四概览页一眼看清家底)
|
||||
- [五、用量大屏:交互式分析](#五用量大屏交互式分析)
|
||||
@@ -35,7 +36,7 @@
|
||||
一次典型的日常是:
|
||||
|
||||
```
|
||||
每天 09:00 / 17:00 系统自动采集(你什么都不用做)
|
||||
每个账号按自己配的时刻自动采集(默认 09:00 / 17:00,你什么都不用做)
|
||||
↓
|
||||
你想看看进度 → 打开「概览」看今天用了多少
|
||||
想深挖 → 打开「用量大屏」按模型/客户端/时段切
|
||||
@@ -45,7 +46,9 @@
|
||||
|
||||
---
|
||||
|
||||
## 二、登录与账号
|
||||
## 二、登录、注册与账号
|
||||
|
||||
### 2.1 登录
|
||||
|
||||
打开 `http://<部署机器IP>:8848`,会看到登录页。
|
||||
|
||||
@@ -55,34 +58,111 @@
|
||||
|---|---|
|
||||
| 默认账号 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) |
|
||||
| 登录保持 | 12 小时 |
|
||||
| 失败限制 | 同一 IP 连续错 5 次,锁定 10 分钟 |
|
||||
| 验证码 | 默认**始终要求**,4 位,不区分大小写,5 分钟内有效、只能用一次 |
|
||||
| 失败限制 | 同一 IP、或同一用户名连续错 5 次,锁定 10 分钟 |
|
||||
| 退出 | 右上角「退出」(走 POST,防被恶意链接静默触发) |
|
||||
|
||||
> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。
|
||||
> 改法:「配置管理 → 修改密码」,或命令行 `python manage.py passwd admin 新密码`。
|
||||
**关于验证码**:
|
||||
|
||||
### 权限差别
|
||||
- 图上只有数字与大写字母,并且**去掉了容易看错的 `0 O 1 I L`**;
|
||||
- 看不清就**点图片换一张**,不消耗任何额度;
|
||||
- 一张验证码**用完即废**:输错要换新的,登录用过之后也不能再拿去注册;
|
||||
- 答案只存在服务器数据库里,浏览器拿到的只是一个随机编号——**在网页源码里搜不到答案**;
|
||||
- 被锁定期间,即使验证码填对也会被拒,等 10 分钟或换一个来源。
|
||||
|
||||
> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。
|
||||
> 改法:「个人中心 → 修改登录密码」,或命令行 `python manage.py passwd admin 新密码`。
|
||||
|
||||
### 2.2 自助注册
|
||||
|
||||
登录页底部有「**自助注册**」入口(地址是 `/register`)。管理员也可以把这个入口关掉。
|
||||
|
||||

|
||||
|
||||
| 字段 | 要求 |
|
||||
|---|---|
|
||||
| 用户名 | 3~32 位,字母或数字开头,可含 `_` `.` `-`;**这是登录名,注册后不可改** |
|
||||
| 显示名 | 选填,留空则与用户名相同 |
|
||||
| 邮箱 | 选填,便于日后找回 |
|
||||
| 密码 | 至少 8 位,且含大写字母 / 小写字母 / 数字 / 符号中的**至少两类** |
|
||||
| 验证码 | 与登录页同款:5 分钟有效、一次性 |
|
||||
|
||||
批量注册被三道闸门挡着:
|
||||
|
||||
1. **图形验证码** —— 每次提交都要重新过一遍;
|
||||
2. **来源限额** —— 同一个 IP 每天最多注册 3 个账号(管理员可调);
|
||||
3. **总开关** —— 管理员可以随时关闭注册入口。
|
||||
|
||||
> 注册成功后**不会**自动帮你配好采集。你要粘贴的是**你自己账号**的 Cookie,
|
||||
> 见 [第三章](#三获取并填写-cookie)。在那之前,概览页只会提示「未配置凭证」。
|
||||
|
||||
### 2.3 个人中心
|
||||
|
||||
点右上角**你自己的名字**,进入个人中心(`/profile`)。
|
||||
|
||||

|
||||
|
||||
| 区块 | 能做什么 |
|
||||
|---|---|
|
||||
| 四张卡片 | 我的记录数 / 我的积分 / 采集次数 / 我的 Cookie 状态 |
|
||||
| 修改资料 | 改显示名、邮箱(用户名只读) |
|
||||
| 修改登录密码 | 需要原密码;改完当前会话仍然有效 |
|
||||
| 我的采集凭证 | 是否已配置、多少字符、结尾 4 位、最后更新时间、当前调度时刻 |
|
||||
|
||||
> 卡片上的「我的积分」只统计**归属你本人的数据**,别人账号的记录不会算进来。
|
||||
|
||||
### 2.4 你的数据边界
|
||||
|
||||
这是多用户版最要紧的一条:**每个账号只看得到、也只影响自己的数据。**
|
||||
|
||||
| 是「你的」 | 是「共用的」 |
|
||||
|---|---|
|
||||
| Cookie 与 User-Agent | 接口基址 / 接口路径 |
|
||||
| 采集参数(分页、超时、截断…) | 是否开放自助注册、注册限额 |
|
||||
| 调度开关与每日时刻 | 验证码策略与位数 |
|
||||
| 用量记录、采集历史、导出的 CSV | 数据库文件本身 |
|
||||
|
||||
两点值得记牢:
|
||||
|
||||
- **管理员也看不到你的 Cookie 和用量明细。** 用户管理页只显示每个账号的记录条数与积分合计,
|
||||
点不进去看内容;Cookie 在页面上永远只回显「长度 + 结尾 4 位」。
|
||||
- **采集只使用本人的凭证。** 系统不会拿别人的 Cookie 去替你采集(那会串号),
|
||||
所以每个账号都必须各自配一次 Cookie。
|
||||
|
||||
### 2.5 权限差别
|
||||
|
||||
| 能力 | 管理员 | 普通用户 |
|
||||
|---|---|---|
|
||||
| 看概览 / 大屏 / 明细 / 任务 / 配置 / 日志 | ✅ | ✅ |
|
||||
| 手动触发采集、补采、改配置 | ✅ | ✅ |
|
||||
| 导出 CSV | ✅ | ✅ |
|
||||
| **用户管理**(建号 / 改权限 / 删号) | ✅ | ❌(导航里不显示,直接访问返回 403) |
|
||||
| 概览 / 大屏 / 明细 / 任务 / 配置 / 日志 / 个人中心 | ✅ | ✅ |
|
||||
| 改**自己**的采集参数、调度时刻、Cookie | ✅ | ✅ |
|
||||
| 手动采集、按区间补采 | ✅(只动自己的数据) | ✅(只动自己的数据) |
|
||||
| 导出 CSV | ✅(只有自己的) | ✅(只有自己的) |
|
||||
| 整理数据库(VACUUM,整库操作) | ✅ | ❌ |
|
||||
| 改**实例级**设置(接口地址、开放注册、验证码策略、注册限额) | ✅ | ❌(输入框置灰) |
|
||||
| 应用日志尾部 | ✅ | ❌(接口 403,页面上该区块为空) |
|
||||
| **用户管理**(建号 / 停用 / 删号 / 改权限) | ✅ | ❌(导航里不显示,直接访问返回 403) |
|
||||
|
||||
> 给只读同事发普通账号即可,没必要共用管理员。
|
||||
> 给同事发普通账号即可,没必要共用管理员——管理员是能停用别人账号的角色。
|
||||
|
||||
---
|
||||
|
||||
## 三、获取并填写 Cookie
|
||||
|
||||
**没有 Cookie,采集一定失败。** 这是首次部署唯一的必要手工步骤。
|
||||
**没有 Cookie,采集一定失败。** 这是每个账号**各自**要做一次的手工步骤。
|
||||
|
||||
### 3.1 为什么要 Cookie
|
||||
### 3.1 为什么要 Cookie,以及它怎么被保管
|
||||
|
||||
采集是直接调账号的用量接口,云端用 Cookie 认人。Cookie 是账号凭证,所以它:
|
||||
- 存在数据库里,页面上**只回显掩码**(如 `a1b2…f9`);
|
||||
- 不会被任何接口以明文返回。
|
||||
采集是直接调账号的用量接口,云端靠 Cookie 认人。Cookie 等于账号凭证,所以系统对它:
|
||||
|
||||
- **加密后入库**:落库前用 ChaCha20 + HMAC-SHA256 加密(密钥在 `data/instance.json`),
|
||||
数据库文件被拷走也读不出明文;
|
||||
- **永不回传明文**:页面与接口只回显「多少字符、结尾 4 位」,形如 `1238 字符,结尾 …c0ffe`;
|
||||
- **只属于你**:存在你的账号名下,别人(包括管理员)看不到、也拿不到;
|
||||
- **和 User-Agent 绑在一起**:两者必须取自**同一次浏览器请求**,否则云端会认为是另一个客户端。
|
||||
|
||||
> 「配置管理 → 我的云端凭证」里如果出现 **无法解密** 的红字提示,说明实例主密钥被换过
|
||||
> (`data/instance.json` 被删或被替换),重新粘贴一次即可。详见
|
||||
> [十二、常见问题](#cookie-显示无法解密)。
|
||||
|
||||
### 3.2 拿 Cookie 的两种办法
|
||||
|
||||
@@ -91,12 +171,17 @@
|
||||
如果你平时用 VSCode / Cursor / Trae 登录过 WorkBuddy,Cookie 已经在本机设置里:
|
||||
|
||||
```bash
|
||||
python manage.py import-creds
|
||||
python manage.py import-creds # 不指定 -u 时给「管理员」账号导入
|
||||
python manage.py import-creds -u alice # 想导给谁就写谁的用户名
|
||||
```
|
||||
|
||||
它会去读编辑器 `settings.json` 里的 `codebuddyUsage.*` 字段,写进数据库。
|
||||
Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器)。
|
||||
|
||||
> ⚠️ 导入的是**运行这条命令的那台机器上、那个编辑器账号**的 Cookie。
|
||||
> 如果 A 同事的机器上跑这条命令去给 B 同事的账号导入,采到的就是 A 的数据——
|
||||
> 所以更稳的做法是让每个人自己登录网页、粘贴自己的 Cookie。
|
||||
|
||||
**办法 B:手工复制(一定可行)**
|
||||
|
||||
1. 浏览器打开并登录 WorkBuddy 官网;
|
||||
@@ -116,6 +201,8 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
|
||||
|---|---|---|
|
||||
| `新增 N 条` 或 `无新增(已是最新)` | ✅ 正常 | — |
|
||||
| `cookie_expired` / `401` / `403` | Cookie 过期了 | 重新执行 3.2 |
|
||||
| `no_cookie`(采集被跳过) | 这个账号**还没配** Cookie | 按 3.2 填一份 |
|
||||
| `cookie_broken` | 密文解不开(实例主密钥被换过) | 重新粘贴一次,见 [十二](#cookie-显示无法解密) |
|
||||
| `TLS` / `SSLError` | 证书校验失败 | 见「十二、常见问题」 |
|
||||
|
||||
---
|
||||
@@ -228,6 +315,10 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
|
||||
> 调度线程在 Web 进程内,所以「关掉 Web」等于「关掉调度」。
|
||||
> 如果偶尔忘了开机,靠「启动补跑」把错过的时刻补回来。
|
||||
|
||||
> **调度是按账号配置的**:你在这里改开关与时刻,只影响**你自己**的采集。
|
||||
> 到达时刻时,系统会逐个账号跑——没配 Cookie 的账号会被跳过并在日志里记一条
|
||||
> `no_cookie`,不会影响别人。别人也可以在别的时间点采,互不干扰。
|
||||
|
||||
### 7.2 手动采集
|
||||
|
||||
- **立即采集一次**:按断点续采,最常用的按钮。
|
||||
@@ -249,19 +340,23 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
|
||||
|
||||

|
||||
|
||||
### 8.1 凭证
|
||||
### 8.1 我的云端凭证
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| Cookie | 采集用的账号凭证。**只回显掩码**;留空保存 = 不修改(不会被清空) |
|
||||
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳 |
|
||||
| Cookie | **你本人账号**的凭证,密文入库。留空保存 = 不修改;填一个 `-` = 清空已保存的 Cookie |
|
||||
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳(**两者必须取自同一次请求**) |
|
||||
|
||||
保存后页面上只显示 `当前 Cookie:1238 字符,结尾 …c0ffe(2026-09-15 10:22 更新)`——
|
||||
页面上、接口里都拿不到明文。若显示「**无法解密**」的红字横幅,说明实例主密钥被换过,
|
||||
重新粘贴一次即可。
|
||||
|
||||
### 8.2 采集参数
|
||||
|
||||
| 参数 | 默认 | 范围 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `api_base` | `https://www.workbuddy.cn` | — | 接口基址(镜像 / 代理时改) |
|
||||
| `api_path` | `/billing/meter/get-user-request-usage` | — | 接口路径 |
|
||||
| `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 | 云端时间比本地早超过该值才告警 |
|
||||
@@ -273,19 +368,40 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
|
||||
> **写错的值会被当场拒绝**并提示原因,不会污染配置(历史版本会因为一个手滑的数字
|
||||
> 让采集整个跑不起来)。范围外的数、非数字都会在保存时被拦下。
|
||||
|
||||
> 上表里除 `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`(**整库操作**) | 删过数据后回收空间,或 WAL 文件偏大时 | **仅管理员** |
|
||||
|
||||
这些动作**耗时且会占用写权限**,所以有二次确认。执行期间不要重复点击。
|
||||
|
||||
### 8.4 修改密码
|
||||
|
||||
填「当前密码 / 新密码 / 确认新密码」。改完当前会话仍然有效,其他会话需要重新登录。
|
||||
(同样的表单在「个人中心」也有一份。)
|
||||
|
||||
### 8.5 实例级设置(仅管理员可见)
|
||||
|
||||
页面最下方这一块,**只有管理员看得到**,改动对**所有账号**生效:
|
||||
|
||||
| 设置 | 默认 | 说明 |
|
||||
|---|---|---|
|
||||
| 开放自助注册 | 允许 | 关掉后登录页不再显示「自助注册」,只能由管理员建号 |
|
||||
| 同 IP 每日注册上限 | 3 | 防止一个来源批量刷号;范围 1 ~ 50 |
|
||||
| 验证码策略 | 始终要求 | `始终要求` / `仅连续失败 2 次后要求` / `关闭` |
|
||||
| 验证码位数 | 4 | 4 ~ 6 位。位数越多越难被自动识别,也越考验眼力 |
|
||||
|
||||
> **验证码策略怎么选**:默认的「始终要求」最安全;「仅连续失败后要求」对天天登录的人更友好,
|
||||
> 但会给机器人留出 2 次免验证码的尝试机会。**「关闭」只有在前面已经有可信网关时才考虑。**
|
||||
>
|
||||
> 验证码的答案只存在服务端 `captchas` 表里,5 分钟过期、用一次就删——
|
||||
> 所以它不会随会话 Cookie 泄漏出去。
|
||||
|
||||
---
|
||||
|
||||
@@ -302,11 +418,14 @@ Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器
|
||||
Web 进程自身的日志(启动、异常栈、调度动作)。默认展示尾部若干行。
|
||||
|
||||
**3. 操作审计**
|
||||
谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号……
|
||||
谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号、注册……
|
||||
可按**动作**筛选,支持翻页。
|
||||
|
||||
> 排错顺序建议:操作审计(有没有人动过) → 采集历史(采集本身成不成功) → 应用日志(程序有没有异常)。
|
||||
|
||||
> **多用户下你看到的范围**:「采集运行历史」与「操作审计」只有你**自己的**记录;
|
||||
> 「应用日志尾部」是整机日志,**仅管理员可见**(普通账号看到的是空区块,接口返回 403)。
|
||||
|
||||
---
|
||||
|
||||
## 十、用户管理页(仅管理员)
|
||||
@@ -315,17 +434,25 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示
|
||||
|
||||
| 操作 | 说明 |
|
||||
|---|---|
|
||||
| 新建账号 | 填用户名 / 显示名 / 密码,可勾选管理员 |
|
||||
| 改显示名 | 行内直接改,保存即生效 |
|
||||
| 改权限 | 管理员 ↔ 普通用户 |
|
||||
| 新建账号 | 填用户名 / 显示名 / 邮箱 / 密码 / 权限(**默认普通账号**) |
|
||||
| 改显示名 | 行内直接改,点该行「保存」生效 |
|
||||
| 改权限 | 管理员 ↔ 普通 |
|
||||
| 改状态 | 启用 ↔ 停用(**停用立即生效**,不必等会话过期) |
|
||||
| 改密码 | 给忘了密码的同事重置 |
|
||||
| 删除 | 删除账号 |
|
||||
| 删除 | **不可逆**,会连同该账号的用量数据与 Cookie 一起删除 |
|
||||
|
||||
内置三条护栏(前端和后端都拦):
|
||||
列表还给出每个账号的**记录条数 / 积分合计 / 最后登录时间与 IP**——但**看不到内容**:
|
||||
管理员能看到的只是「有多少」,看不到「是什么」,也看不到任何人的 Cookie。
|
||||
|
||||
内置四条护栏(前端置灰 + 后端再拦一次):
|
||||
|
||||
1. **不能取消自己的管理员身份**(防止把自己锁在门外);
|
||||
2. **不能删除自己**;
|
||||
3. **至少要保留一个账号**(防止系统变成没人能登录)。
|
||||
2. **不能停用自己**;
|
||||
3. **不能删除自己**;
|
||||
4. **不能删掉最后一个启用的管理员**(防止系统变成没人能管)。
|
||||
|
||||
页面底部是「**账号操作审计**」:最近 20 条账号相关动作,含**注册**与**登录失败**记录——
|
||||
想知道有没有人在撞你的密码,看这里。
|
||||
|
||||
---
|
||||
|
||||
@@ -333,24 +460,74 @@ Web 进程自身的日志(启动、异常栈、调度动作)。默认展示
|
||||
|
||||
| 我想… | 怎么做 |
|
||||
|---|---|
|
||||
| 立刻采集一次 | 任务管理 → 立即采集一次 |
|
||||
| 自己注册一个账号 | 登录页 → **自助注册**(需管理员开放注册) |
|
||||
| 立刻采集一次 | 任务管理 → 立即采集一次(只采我自己的) |
|
||||
| 回补某几天的数据 | 任务管理 → 按区间补采,填起止日期 |
|
||||
| 换 Cookie | 配置管理 → 凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
|
||||
| 换我自己的 Cookie | 配置管理 → 我的云端凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
|
||||
| 改我的显示名 / 邮箱 / 密码 | 右上角**点自己的名字** → 个人中心 |
|
||||
| 看不清验证码 | **点验证码图片**换一张 |
|
||||
| 导出某段时间的数据给别人 | 数据明细 → 选日期 → 导出 CSV |
|
||||
| 导出全量存档 | 配置管理 → 维护动作 → 导出全量 CSV |
|
||||
| 导出我的全量存档 | 配置管理 → 维护动作 → 导出我的 CSV |
|
||||
| 找出最贵的请求 | 用量大屏 → 单笔 TOP |
|
||||
| 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 |
|
||||
| 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 |
|
||||
| 同事忘记密码 | 用户管理 → 该行「改密码」 |
|
||||
| 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出全量 CSV |
|
||||
| 给同事开账号 | 用户管理 → 新建账号,权限选**普通**(或让同事自助注册) |
|
||||
| 同事忘记密码 | 用户管理 → 该行「改密」 |
|
||||
| 临时封掉某个账号 | 用户管理 → 该行「停用」(数据与 Cookie 保留) |
|
||||
| 拒绝别人自助注册 | 配置管理 → 实例级设置 → 开放自助注册 → 关闭 |
|
||||
| 把数据备份走 | 让运维按 [部署指南 6.2](DEPLOYMENT.md#62-备份) 备份命名卷,或在「配置管理」导出 CSV |
|
||||
| 关掉自动采集 | 任务管理 → 关「启用调度」 |
|
||||
| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 |
|
||||
| 系统变慢了 | 配置管理 → 整理数据库;再不行看「十二」 |
|
||||
| 系统变慢了 | 让**管理员**做「配置管理 → 整理数据库」;再不行看「十二」 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、常见问题
|
||||
|
||||
### 验证码看不清
|
||||
|
||||
点验证码图片**换一张**,不限次数、不消耗额度。图上刻意去掉了 `0 O 1 I L` 这几个易混字符,
|
||||
只剩数字与不含它们的字母。也可以让管理员把「验证码位数」调成 4 位。
|
||||
|
||||
### 验证码明明填对了,还是提示错误
|
||||
|
||||
三种可能,按顺序排查:
|
||||
|
||||
1. **这张图已经用过了** —— 验证码是**一次性**的,输错一次、或登录成功之后,它立刻作废,
|
||||
必须点图片重新取一张;
|
||||
2. **超过了 5 分钟** —— 有效期只有 5 分钟,慢慢来的话会过期,换一张即可;
|
||||
3. **跨了页面** —— 登录页取到的图不能拿去注册页用(两边的验证码是分开的)。
|
||||
|
||||
> 另外:如果这个来源已被锁定(连续失败 5 次),即使验证码正确也会被拒;等 10 分钟再试。
|
||||
|
||||
### 登录页看不到「自助注册」
|
||||
|
||||
说明管理员把注册关掉了。两条路:请管理员在「配置管理 → 实例级设置」里打开,
|
||||
或直接请管理员在「用户管理」里给你建一个账号。
|
||||
|
||||
### 注册被拒,说来源已达上限
|
||||
|
||||
同一个 IP 每天默认最多注册 3 个账号。换个网络,或请管理员把
|
||||
「同 IP 每日注册上限」调大(范围 1 ~ 50)。
|
||||
|
||||
### 「Cookie 显示无法解密」
|
||||
|
||||
「配置管理 → 我的云端凭证」出现红字横幅,或卡片上写着 **无法解密**:这是说数据库里
|
||||
存的 Cookie 密文,用当前的实例主密钥解不开了。常见原因是 `data/instance.json`
|
||||
(里面存着 `cookie_key`)被删除、被替换,或者从别的机器拷了一份数据库过来。
|
||||
|
||||
**影响**:这个账号的采集会失败,日志里是 `cookie_broken`。
|
||||
|
||||
**怎么办**:重新粘贴一次这个账号的 Cookie 即可,历史数据不受影响。
|
||||
**怎么避免**:`data/instance.json` 里存着会话签名密钥和加密主密钥——备份数据库时
|
||||
**把它一起备份**,并且不要在容器之间混用。
|
||||
|
||||
### 我能不能看别人的用量
|
||||
|
||||
不能,管理员也不能。「用户管理」页只显示每个账号的记录条数与积分合计,看不到内容。
|
||||
这是设计如此:Cookie 是账号级凭证,让它跨账号可见等于把别人的账号交出去。
|
||||
|
||||
如果确实需要合并统计,正确做法是让每个人各自导出 CSV,再在外部合并。
|
||||
|
||||
### 采集报 `cookie_expired` / `unauthorized`
|
||||
|
||||
Cookie 过期。重新按 [3.2](#32-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。
|
||||
@@ -391,13 +568,27 @@ Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。
|
||||
在的。调度在**服务端进程**里,和浏览器无关。要停就去「任务管理」关调度开关,
|
||||
或停掉服务。
|
||||
|
||||
### 忘记管理员密码
|
||||
### 忘记密码
|
||||
|
||||
在部署机器上执行:
|
||||
**你自己的密码忘了**:网页上没法自助重置(没有邮件通道),找管理员在
|
||||
「用户管理 → 该行『改密』」给你设一个新的。
|
||||
|
||||
**管理员密码忘了**(或者被自己停用了),到部署机器上执行:
|
||||
|
||||
```bash
|
||||
python manage.py passwd admin 新密码 # 裸机
|
||||
python manage.py passwd admin 新密码 # 裸机
|
||||
docker compose exec portal python manage.py passwd admin 新密码 # Docker
|
||||
|
||||
# 顺便把被停用的账号恢复启用
|
||||
python manage.py passwd admin 新密码 --activate
|
||||
# 需要新建一个管理员
|
||||
python manage.py passwd alice 密码 --role admin
|
||||
```
|
||||
|
||||
想看现在都有哪些账号、各自什么角色与状态,用:
|
||||
|
||||
```bash
|
||||
python manage.py users
|
||||
```
|
||||
|
||||
### 数据会丢吗
|
||||
@@ -417,4 +608,4 @@ docker compose exec portal python manage.py passwd admin 新密码 # Docker
|
||||
---
|
||||
|
||||
更多技术细节见 [架构与设计说明](ARCHITECTURE.md)、[部署与运维指南](DEPLOYMENT.md)、
|
||||
[接口参考](API.md)。
|
||||
[接口参考](API.md)、[安全说明](../SECURITY.md)。
|
||||
|
||||
|
之前 宽度: | 高度: | 大小: 93 KiB 之后 宽度: | 高度: | 大小: 336 KiB |
|
之前 宽度: | 高度: | 大小: 169 KiB 之后 宽度: | 高度: | 大小: 468 KiB |
|
之前 宽度: | 高度: | 大小: 478 KiB 之后 宽度: | 高度: | 大小: 535 KiB |
|
之前 宽度: | 高度: | 大小: 269 KiB 之后 宽度: | 高度: | 大小: 418 KiB |
|
之前 宽度: | 高度: | 大小: 140 KiB 之后 宽度: | 高度: | 大小: 382 KiB |
|
之前 宽度: | 高度: | 大小: 271 KiB 之后 宽度: | 高度: | 大小: 487 KiB |
|
之前 宽度: | 高度: | 大小: 98 KiB 之后 宽度: | 高度: | 大小: 458 KiB |
|
之前 宽度: | 高度: | 大小: 386 KiB 之后 宽度: | 高度: | 大小: 476 KiB |
|
之前 宽度: | 高度: | 大小: 386 KiB 之后 宽度: | 高度: | 大小: 476 KiB |
|
之后 宽度: | 高度: | 大小: 339 KiB |
|
之后 宽度: | 高度: | 大小: 386 KiB |