chore: 项目定名为 workbuddy-portal,容器化并补齐文档体系

## 项目定名
- 目录 wb_usage_portal → workbuddy-portal
- Python 包 wb_usage → workbuddy_portal(含 session cookie 名)
- 界面品牌统一为 WorkBuddy Portal;项目标识收敛到 config 单一来源

## 容器化
- Dockerfile:多阶段构建,依赖层与源码解耦;非 root(uid 1000);内置健康检查
- docker-compose.yml:单服务 + 绑定挂载 data/logs + 日志轮转 + TZ
- docker/entrypoint.sh:幂等初始化 → exec serve(LF 行尾,已由 .gitattributes 锁定)
- docker/healthcheck.py:纯标准库探活 /login(slim 镜像无 curl)
- .dockerignore / .env.example;数据目录可用 WB_DATA_DIR 等环境变量覆盖

## 文档
- docs/USER-GUIDE.md    用户使用手册(含 9 张真实界面截图)
- docs/DEPLOYMENT.md    部署运维(Docker / 裸机 / 反代 / 备份 / 推 Gitea 注册表)
- docs/ARCHITECTURE.md  架构与设计说明(含已知坑与红线、验证体系)
- docs/API.md           接口参考(路径 / 参数 / 返回结构 / 错误码)
- docs/FAQ.md           常见问题;docs/CHANGELOG.md 变更日志

## 修复缺陷(8)
1. /records/export 必然 500:生成器在请求上下文销毁后才迭代,改用自建连接
2. 大屏页图表全白:相对路径把 echarts.min.js 解析成 /vendor/... → 404
3. /users 500:路由已注册但模板缺失
4. 明细页日期筛选失效:视图传 f.frm、模板读 f.from
5. 配置页维护按钮全死:调用了不存在的 WBU.bindMaint()
6. 审计只能看最近 40 条:LIMIT 写死
7. 明细页多跑一条无用 SELECT:day_list() 取了没人用
8. 登录页锁定阈值未从配置注入

## 安全加固
- 新增 safe_next():拒绝 //evil.com 等协议相对 URL 的开放重定向
- 缺 CSRF 的写请求统一 400
- 默认开启云端 HTTPS 证书校验(ssl_verify=1);Cookie 是账号凭证
- 登录失败计数表加上限与 TTL
- /logout 拆分为 POST(执行) + GET(仅提示),防 <img src=/logout> 静默退出
- settings 内部簿记键 slot:* 读写两侧过滤,不再从 /api/settings 泄漏

## 内部质量与工具
- 设置项写时校验 + 读时兜底,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 ValueError → 400:手写 query string 不再暴露 500 页面
- CSV 导出改 csv.writer 流式写入(原手工拼串,字段含逗号会串列)
- bundle 明细加 20000 上限并回传 recordsTotal/recordsTruncated,不静默丢数据
- tools/smoke.py 离线回归 99 项;tools/check_live.py 真实 HTTP 56 项
- tools/shots.py Playwright 逐页截图 + JS 报错收集

## 验证
- compileall 通过;smoke 99/99;对容器实例 check_live 56/56;截图 0 JS 报错
- 容器内采集实测成功(trigger=startup 补跑:新增 11 条)
这个提交包含在:
2026-09-14 14:55:50 +08:00
当前提交 86631ae7ab
共修改 58 个文件,包含 9409 行新增和 0 行删除
+412
查看文件
@@ -0,0 +1,412 @@
# 接口参考(API)
> 面向**开发 / 集成**。所有接口都在 `/api` 前缀下,返回 JSON。
**通用约定**
| 项 | 说明 |
|---|---|
| 认证 | 全部需要登录。`/api/*` 未登录返回 **401** JSON(页面则跳登录页) |
| CSRF | **写接口**(`POST`)需带 `X-CSRF-Token` 头,或表单域 `_csrf`;缺失 / 错误返回 **400** |
| 日期参数 | `from` / `to`,`YYYY-MM-DD`。也容忍 `YYYY/MM/DD`、带时间的写法;起止写反会自动交换 |
| 非法参数 | 无法识别时返回 **400** 且带人话说明(如 `参数 from 不是合法日期:abc(正确写法 2026-09-08)`),**不会 500** |
| 分页 | `page`(默认 1)+ `size`(默认 50,上限 500) |
| 权限 | 标「管理员」的接口非管理员访问返回 **403** |
| 时区 | 所有日期口径按服务端本地时区(Docker 由 `TZ` 决定) |
**错误响应形状**
```json
{ "ok": false, "error": "bad_request", "message": "参数 from 不是合法日期:abc(正确写法 2026-09-08)" }
```
| `error` | HTTP | 含义 |
|---|---|---|
| `bad_request` | 400 | 参数不合法 / 缺 CSRF / 业务校验失败(`message` 说明原因) |
| `unauthorized` | 401 | 未登录 |
| `forbidden` | 403 | 已登录但权限不足 |
| `not_found` | 404 | 接口或资源不存在 |
| `busy` | 409 | 已有采集在跑(单写者约束) |
| `cookie_expired` | 401 | 云端 Cookie 失效,需去「配置管理」更新 |
| `api` | 502 | 云端接口异常 |
| `internal` | 500 | 服务端异常 |
---
## 读接口
### GET `/api/manifest`
存档总览。无损、代价小,适合做探活与元数据展示。
```json
{
"schema": 4,
"generated": "2026-09-14 14:52:20",
"archive": "workbuddy-portal",
"producer": "workbuddy-portal(Flask + SQLite)",
"note": "...",
"totals": {
"records": 1665, "credits": 8513.36, "calls": 1086,
"freeCalls": 579, "billableCalls": 507,
"models": 12, "clients": 3,
"first": "2026-08-10 00:00:00", "last": "2026-09-14 14:51:00",
"topCredits": 319.5
},
"months": ["2026-08", "2026-09"],
"sources": [{ "path": "usage.sqlite", "role": "primary", "count": 1665, "bytes": 1234567 }],
"focusDay": "2026-09-14",
"health": { "cookie": true, "lastRunAt": "2026-09-14 14:51:28", "lastRunStatus": "ok" }
}
```
### GET `/api/bundle`
**大屏页专用**:一次取齐所有需要的数据,避免切页时多次往返。
| 参数 | 必填 | 默认 | 说明 |
|---|---|---|---|
| `from` / `to` | 否 | 全量 | 筛选窗口 |
| `topN` | 否 | 30 | 「单笔 TOP」返回条数(1~1000) |
```json
{
"manifest": { ... 同上 ... },
"daily": [
{ "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
"m": { "deepseek-v4-flash": 800.2, "glm-5.3-flash": 623.3 },
"h": [0,0,0,0,0,0,0,0,0,12.5, ...] }
],
"dims": { "model": [...], "client": [...], "hour": [...] },
"top": [ { "id": "...", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode", "t": "2026-09-12 15:04:00", "px": "摘要…" } ],
"records": [ { "id": "...", "c": 5.78, "m": "...", "cl": "...", "t": "...", "px": "..." } ],
"recordsTotal": 1665,
"recordsCap": 20000,
"recordsTruncated": false,
"totals": { ... },
"window": { "from": "...", "to": "...", "days": 7 }
}
```
**关键语义**(改动前务必先读 [架构说明](ARCHITECTURE.md#五聚合层边界在哪)):
| 字段 | 范围 | 说明 |
|---|---|---|
| `daily` | **全量** | 不受 `from`/`to` 影响(约 200 B/天),供日历与日期轴 |
| `top` | **全局** | 不受窗口影响,否则排名会随筛选跳动 |
| `dims` / `records` / `totals` | 随窗口 | 筛选真正影响的部分 |
| `records` | 有上限核心集 | 超过 `recordsCap` 时只发**最新 N 条**,`recordsTruncated=true` |
> **字段名是短键**(`d/c/k/m/cl/t/px`),沿用旧版 `dashboard/data/*.json` 的契约,
> 大屏页的渲染代码依赖它。`/api/records` 用的是可读全名。**两套契约不要互相「统一」。**
### GET `/api/summary`
KPI + 环比。`from`/`to` 缺省时自动取全量区间。
```json
{
"records": 428, "credits": 2145.6, "calls": 300,
"freeCalls": 120, "billableCalls": 180,
"firstDay": "2026-09-08", "lastDay": "2026-09-14", "days": 7,
"models": 9, "clients": 2,
"first": "...", "last": "...",
"window": { "from": "2026-09-08", "to": "2026-09-14", "days": 7 },
"avgPerCall": 7.15,
"prev": { ... 上一段等长窗口的同样结构 ... },
"delta": { "credits": 12.3, "calls": -4.1, "window": { "from": "...", "to": "..." } },
"partial": { "date": "2026-09-14", "hhmm": "14:52" }
}
```
| 字段 | 说明 |
|---|---|
| `prev` | 紧邻的前一段**等长**窗口。若该段不在存档内,其值为空/零——**不给假数字** |
| `delta` | 相对 `prev` 的变化百分比 |
| `partial` | 窗口末尾那天是「今天」,数据尚未走完,据此提示 |
### GET `/api/daily`
| 参数 | 说明 |
|---|---|
| `from` / `to` | 筛选窗口 |
```json
{ "days": [ { "d": "2026-09-08", "c": 1423.5, "k": 88, "fc": 40, "bc": 48,
"m": {...}, "h": [24 个元素] } ] }
```
`h` 是 24 个时段的积分数组,索引即小时。
### GET `/api/dims`
| 参数 | 默认 | 说明 |
|---|---|---|
| `dim` | 全部 | `model` / `client` / `hour`,只返回该维度 |
```json
{
"model": [ { "name": "deepseek-v4-flash", "credits": 3784.86, "calls": 234, "avg": 16.17, "free": 0 } ],
"client": [ { "name": "VSCode", ... } ],
"hour": [ { "name": "14", ... } ]
}
```
### GET `/api/top`
单笔消耗榜。**唯一会返回 Prompt 摘要的列表接口。**
| 参数 | 默认 | 说明 |
|---|---|---|
| `from` / `to` | 全量 | 筛选窗口 |
| `n` | 50 | 返回条数(1~1000) |
```json
[ { "id": "…", "c": 319.5, "m": "kimi-k3-1", "cl": "VSCode",
"t": "2026-09-12 15:04:00", "px": "截断后的 Prompt 摘要" } ]
```
### GET `/api/records`
明细分页。**字段用可读全名**(与导出、Jinja 表格一致)。
| 参数 | 默认 | 说明 |
|---|---|---|
| `from` / `to` | 全量 | 日期窗口 |
| `model` / `client` | — | 精确匹配 |
| `q` | — | 在 Prompt 里模糊匹配 |
| `page` | 1 | 页码 |
| `size` | 50 | 每页条数(1~500) |
| `order` | `ts_desc` | `ts_desc` / `ts_asc` / `credits_desc` 等 |
| `lean` | — | `1` = 不返回 Prompt 全文(省约 80% 体积) |
```json
{
"items": [ { "request_id": "…", "credits": 5.78, "model": "…",
"client": "…", "ts": "2026-09-14 14:20:00", "prompt": "…" } ],
"total": 1665, "page": 1, "size": 50, "pages": 34,
"window": { "from": "...", "to": "..." }
}
```
### GET `/api/records/<request_id>`
单条详情,返回整行字段(`SELECT *`)。不存在返回 404 `{"ok":false,"message":"记录不存在"}`。
### GET `/api/runs` · GET `/api/runs/<id>`
| 参数 | 默认 | 说明 |
|---|---|---|
| `limit` | 50 | 返回条数(1~500) |
`/api/runs` → `{ "items": [ {id, trigger, status, started_at, finished_at, duration_ms, win_from, win_to, fetched, added, dup, total, conflicts, exit_code, message} ] }`
`/api/runs/<id>` → 单次详情,**额外含 `detail`**(逐行日志原文)。
### GET `/api/status`
```json
{
"server_time": "2026-09-14 14:52:20",
"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
}
```
### GET `/api/audit`
操作审计分页。
| 参数 | 默认 | 说明 |
|---|---|---|
| `action` | — | 按动作精确筛选(值取自返回的 `actions`) |
| `page` / `size` | 1 / 50 | 分页(`size` 上限 500) |
```json
{
"total": 120, "page": 1, "size": 50, "pages": 3,
"actions": ["collect", "login", "login_failed", "settings", "settings_rejected", "user_create", ...],
"items": [ { "id": 300, "at": "2026-09-14 14:52:20", "actor": "admin",
"action": "login", "detail": "登录成功", "ip": "127.0.0.1" } ]
}
```
### GET `/api/settings`
读配置。**`cookie` 只回掩码**,绝不回明文;内部簿记键(`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"]
}
```
### GET `/api/users`(管理员)
```json
{ "items": [ { "id": 1, "username": "admin", "display_name": "管理员",
"is_admin": 1, "created_at": "...", "last_login_at": "...", "login_count": 12 } ] }
```
> **口令散列永不出现在响应里。**
### GET `/logs/tail`
应用日志尾部。
| 参数 | 默认 | 说明 |
|---|---|---|
| `lines` | 200 | 行数(自动钳制;非法值回落默认而非 500) |
### GET `/records/export`
按当前筛选**流式**导出 CSV(不受分页上限约束)。
| 参数 | 说明 |
|---|---|
| `from` / `to` / `model` / `client` / `q` / `order` | 同 `/api/records` |
响应:`text/csv; charset=utf-8`,带 **UTF-8 BOM**(Excel 双击不乱码),
表头与官网导出 xlsx 同构:`RequestID, 积分消耗, User Prompt, 模型, 客户端, 时间`。
---
## 写接口
> 全部需要 `X-CSRF-Token`(页面里读 `window.WB_CSRF`)。
### POST `/api/collect`
手动触发采集(同步执行,页面等待结果)。
请求体(都可选):
```json
{ "from": "2026-09-01", "to": "2026-09-07" }
```
| 场景 | 响应 |
|---|---|
| 成功 | `200 {"ok": true, "result": {"message": "新增 11 条,重复 6 条,存档共 1665 条", ...}}` |
| 已有采集在跑 | `409 {"ok": false, "error": "busy", "message": "..."}` |
| Cookie 失效 | `401 {"ok": false, "error": "cookie_expired", "message": "..."}` |
| 云端异常 | `502 {"ok": false, "error": "api", "message": "..."}` |
| 日期不合法 | `400 {"ok": false, "error": "bad_request", "message": "起始日期不合法:..."}` |
### POST `/api/maintenance/<action>`
把 CLI 维护动作搬到页面。
| `action` | 作用 |
|---|---|
| `fill-prompt` | 从云端回补缺失的 Prompt |
| `export-csv` | 全量导出 CSV 到 `data/exports/` |
| `vacuum` | `wal_checkpoint(TRUNCATE)` + `VACUUM` |
| `recount` | 重新统计并返回当前条数 |
未知动作返回 **404**。成功返回 `{"ok": true, "message": "..."}`。
### POST `/api/settings`
写配置。**逐项校验**,一次返回全部错误。
请求体:`{ "page_size": "300", "schedule_times": "08:00,12:00,18:00", ... }`
| 场景 | 响应 |
|---|---|
| 全部合法 | `200 {"ok": true, "changed": ["page_size"], "ignored": []}` |
| 有非法值 | `400 {"ok": false, "error": "invalid", "errors": ["page_size 需在 20 ~ 1000 条/页 之间"]}` |
要点:
- `cookie` **留空 = 不修改**(不会把已有 Cookie 清掉);
- 未知键被忽略并在 `ignored` 里列出,**不会被写成任意键**;
- 内部键(`slot:*`)被忽略;
- 每次拒绝都会写一条 `settings_rejected` 审计。
### POST `/api/password`
修改**自己**的密码。
```json
{ "old": "旧密码", "new": "新密码", "new2": "新密码" }
```
校验:非空、两次一致、长度上限 128。旧密码错误返回 400。
### POST `/api/users`(管理员)
```json
{ "username": "viewer", "display_name": "只读同事", "password": "…", "is_admin": false }
```
### POST `/api/users/<id>`(管理员)
```json
{ "display_name": "新名字", "is_admin": true, "password": "可选,重置密码" }
```
### POST `/api/users/<id>/delete`(管理员)
删除账号。**三重护栏**(服务端强制):
1. 不能取消自己的管理员身份;
2. 不能删除自己;
3. 至少保留一个账号。
违反返回 `400`。
---
## 集成示例
### curl(读接口)
```bash
# 1) 登录拿会话 + CSRF(登录页的 window.WB_CSRF)
curl -s -c /tmp/cj --noproxy '*' http://127.0.0.1:8848/login \
| grep -o 'window.WB_CSRF = "[^"]*"'
CSRF=$(curl -s -c /tmp/cj --noproxy '*' http://127.0.0.1:8848/login | sed -n 's/.*WB_CSRF = "\([^"]*\)".*/\1/p')
curl -s -b /tmp/cj -c /tmp/cj --noproxy '*' -X POST \
-d "username=admin&password=admin123&_csrf=$CSRF" http://127.0.0.1:8848/login -o /dev/null
# 2) 调接口
curl -s -b /tmp/cj --noproxy '*' "http://127.0.0.1:8848/api/summary?from=2026-09-08&to=2026-09-14"
```
> `--noproxy '*'` 是本机环境的坑:默认代理会把 `127.0.0.1` 也拦成 502。
### Python(写接口,注意 CSRF)
```python
import http.cookiejar, json, re, urllib.parse, urllib.request
BASE = "http://127.0.0.1:8848"
cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.ProxyHandler({}),
urllib.request.HTTPCookieProcessor(cj))
# 拿到 CSRF(登录页内联注入)
html = op.open(BASE + "/login").read().decode()
csrf = re.search(r'window\.WB_CSRF = "([^"]+)"', html).group(1)
# 登录
op.open(urllib.request.Request(
BASE + "/login",
data=urllib.parse.urlencode({"username": "admin", "password": "admin123",
"_csrf": csrf}).encode()))
# 触发采集(写接口必须带 X-CSRF-Token)
req = urllib.request.Request(BASE + "/api/collect", data=b"{}", method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("X-CSRF-Token", csrf)
print(json.loads(op.open(req).read().decode()))
```
> **登录成功会重置会话与 CSRF 令牌**——登录后要重新读一次页面的 `window.WB_CSRF`,
> 否则写接口会返回 400。
+382
查看文件
@@ -0,0 +1,382 @@
# 架构与设计说明
> 面向**开发 / 维护者**。解释这个系统为什么长这样,以及改动时不能碰的红线。
**目录**
- [一、分层与数据流](#一分层与数据流)
- [二、数据模型](#二数据模型)
- [三、采集:断点、去重、锁](#三采集断点去重锁)
- [四、调度:为什么不用 APScheduler](#四调度为什么不用-apscheduler)
- [五、聚合层:边界在哪](#五聚合层边界在哪)
- [六、呈现层:两套界面共用一套令牌](#六呈现层两套界面共用一套令牌)
- [七、安全模型](#七安全模型)
- [八、配置系统:写时校验 + 读时兜底](#八配置系统写时校验--读时兜底)
- [九、已知坑与红线](#九已知坑与红线)
- [十、验证体系](#十验证体系)
---
## 一、分层与数据流
```
client.py 纯 urllib 调云端;读编辑器 settings.json 取凭证
│
▼
collect.py 断点续采 → 文件锁 → 规范化 → 分批 upsert;导入/导出也在这
│ ▲
│ │ scheduler.py 只是「到点调 collect.sync()」
▼
db.py + schema.sql SQLite(WAL),单写者;settings 表兼作运行期配置
│
▼
query.py 全部聚合下推 SQL:daily / dims / top / records / summary / bundle / manifest
│
├──▶ web/views.py Jinja 后台(7 个页面)
└──▶ web/api.py JSON(大屏 + 页面异步调用)
```
**关键点:没有中间 JSON 层。** 旧版本是「脚本 → CSV → 预生成 JSON → 大屏」,
任何一次查询变化都要重新跑生成器。现在聚合全部下推 SQL,页面与接口共享同一个 `query` 层,
口径不可能不一致。
---
## 二、数据模型
```sql
usage_records(
request_id TEXT PRIMARY KEY, -- 云端请求 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,
prompt TEXT, -- 可截断(max_prompt)
first_seen TEXT, last_seen TEXT,
cloud_ts TEXT -- 云端原始时间,用于漂移检测
)
collect_runs(
id INTEGER PRIMARY KEY, 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)
```
索引:`day`、`(day,hour)`、`(model,day)`、`(client,day)`、`credits DESC`、`ts`。
覆盖了「按天」「按天+时段」「模型/客户端 × 天」「单笔 TOP」「时间排序」五类热点查询。
### 为什么 `day`/`hour` 是冗余列
`substr(ts,1,10)` 这类函数表达式无法走索引。把它们物化成列 + 索引,聚合查询从全表扫描
变成索引扫描。写入时多算一次,读的时候省下 N 次。
### 为什么 `settings` 表兼作簿记
调度槽位去重需要一个「今天 09:00 已经跑过了」的持久标记,正好复用
`settings(key='slot:2026-09-14T09:00', value='done')`。这类键用前缀 `slot:` 标记为
**内部键**:`/api/settings` 读写两侧都过滤掉(`config.is_internal_key()`),
用户不会在配置页看到它们,也无法通过接口写入任意键。
---
## 三、采集:断点、去重、锁
### 断点续采
```python
since = 本地 MAX(ts) - rewind_minutes # 回退几分钟,容忍云端写入延迟
rows = client.fetch_range(since, now) # 分页拉取
```
回退的意义:云端记录可能比本地时间晚落库,卡在边界上的记录会被漏掉。
默认回退 2 分钟,重复拉到已有记录由去重兜住——**宁可重复拉,不可漏**。
### 去重:`ON CONFLICT` + 取更早的时间
```sql
INSERT INTO usage_records(...) VALUES(...)
ON CONFLICT(request_id) DO UPDATE SET
ts = CASE WHEN excluded.ts < usage_records.ts THEN excluded.ts ELSE usage_records.ts END,
...
```
同一条请求可能被多次拉到(断点回退 + 区间补采重叠)。冲突时**以更早的本地 `ts` 为准**,
因为后拉到的可能已经越过了跨日边界。
### 单写者:文件锁
```
collect._Lock() → data/collect.lock (O_CREAT|O_EXCL)
```
三处调用者共享这把锁:调度线程、页面手动触发(`POST /api/collect`)、CLI(`manage.py collect`)。
拿到锁失败 → 抛 `collect.Busy` → API 返回 **409**,页面提示「采集正在进行中」。
锁文件含时间戳,**超过 30 分钟视为僵尸锁可抢占**(进程被 kill 时锁不会自己释放)。
> **为什么不用数据库锁**:SQLite 的写锁是「事务级」的,而一次采集可能持续几分钟
> 且分多个事务提交。用文件锁把「整个采集流程」串起来,比用事务锁正确得多。
### 分批提交
`upsert()` 每 200 行一个显式事务(`BEGIN` / `COMMIT`),并带重入保护:
```python
own_tx = not conn.in_transaction # 已在事务里就别再 BEGIN(会报错)
```
这样中途失败只回滚当前一批,已入库的不受影响。
---
## 四、调度:为什么不用 APScheduler
需求只有「每天几个固定时刻」。一个 20 秒轮询的线程就够:
```python
while True:
now = datetime.now()
for slot in due_slots(now): # 今天该跑但没跑过的时刻
if not already_done(slot): # 靠 settings 里的 slot: 键去重
mark_done(slot)
collect.sync(trigger="schedule")
sleep(20)
```
自研换来三件事**外部调度器给不了**:
1. **启动补跑**:程序没开时错过的时刻,启动后检查「今天已过的时刻」,
在宽限期(`catch_up_grace_hours`,默认 12 小时)内补采。
2. **与 CLI 共享同一把锁**:手动 `manage.py collect` 不会和调度撞车。
3. **零额外依赖**:少一个包,少一类版本冲突。
注意事项:
- `WERKZEUG_RUN_MAIN` 守卫:`--debug` 下 reloader 会 fork 子进程,只允许子进程起调度。
- `WB_DISABLE_SCHEDULER=1` 关掉调度(多副本时给除第一份外的实例用)。
- 轮询间隔 20 秒是折中:时刻精度 ±20 秒足够,且几乎不占 CPU。
---
## 五、聚合层:边界在哪
`query.py` 是唯一的聚合出口。几个刻意的设计:
| 函数 | 边界 | 为什么 |
|---|---|---|
| `manifest()` | 全量 | 存档总览,供页面显示「数据范围」「活跃天数」 |
| `bundle()` 的 `daily` | **全量**(约 200 B/天) | 大屏的日历与日期轴需要完整日期序列 |
| `bundle()` 的 `top` | **全局** | 大屏的「单笔 TOP」不该随窗口变(否则排名会跳) |
| `bundle()` 的 `dims/records/totals` | 随窗口 | 这才是筛选真正影响的部分 |
| `bundle()` 的 `records` | **有上限核心集** | 见下 |
| `summary()` | 窗口 + 上一段 | 环比;前一段不在存档内时**不给假数字**,明确标记 |
### `bundle` 为什么要设上限
大屏是「数据进浏览器 → 控件联动 → 即时重绘」的模型,必须把数据一次性下发。
但全量明细可能有几十万条,直接塞进 JSON 会把浏览器打死。
所以:
```python
BUNDLE_RECORDS_CAP = 20000
```
超过上限时只发**最新的 N 条**,同时返回 `recordsTotal` / `recordsCap` / `recordsTruncated`,
页面据此提示「明细表只显示最近 N 条,完整数据请到数据明细页」。**不静默丢数据**是关键。
### 日期归一化
`norm_day()` 容忍 `2026/09/08`、`2026-09-08 12:00:00`、`T` 分隔;
`norm_window()` 还会自动交换写反的起止。**任何手写 query string 都不该让接口 500。**
---
## 六、呈现层:两套界面共用一套令牌
- **Jinja 后台**:`web/templates/*.html` + `web/static/css/app.css`
- **ECharts 大屏**:`web/static/dashboard/index.html`(单文件,内联样式)
两者**共用同一套设计令牌**(色板 / 圆角 / 间距 / 字号)。大屏是独立静态页,
因为它需要完全自由的布局与 canvas 尺寸,套进导航框架反而受限。
### 大屏页的所有权边界
大屏页有 13 个渲染函数,**只允许改数据层,不允许改渲染逻辑**。原因:
渲染函数经过 Node DOM stub 工装验证(断言「页面聚合 == 独立算出的聚合」),
改动它们会让验证失效。
### 返回值形状契约
大屏页沿用旧版 `dashboard/data/*.json` 的**短键**:
```
daily: d(day) c(credits) k(calls) f(first) b(build) m(models) h(hours[24])
records: id c(credits) m(model) cl(client) t(ts) px(prompt)
```
而 `/api/records`(供 Jinja 表格与导出)用**可读全名**:
`request_id / credits / model / client / ts / 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 小时) |
### 授权
`@login_required`(`/api/*` 未登录返回 401 JSON,页面跳登录)+
`@admin_required`(403)两层。`/users` 与 `/api/users*` 全部要管理员。
内置护栏(服务端强制,前端只是提前提示):不能取消自己的管理员身份、不能删自己、至少留一个账号。
### CSRF
`before_request` 统一校验:`X-CSRF-Token` 头或 `_csrf` 表单域。
**退出登录也走 POST**——GET 型退出能被 `<img src="/logout">` 静默触发。
### 开放重定向
登录跳转的 `next` 只接受站内相对路径。`security.safe_next()` 拒绝:
- `//evil.com`(**协议相对 URL**,浏览器会当成 `http://evil.com`)
- `/\evil.com`、含 `\` 的
- `http://...` / `https://...` 绝对地址
- 含 CR/LF 的(防 header 注入)
### 凭证
- Cookie 只回掩码(`cookie_hint`),页面与接口都不回明文;保存时留空 = 不覆盖。
- 默认 `ssl_verify=1`:Cookie 就是账号凭证,不该在无校验的 TLS 上裸奔。
- 内部簿记键(`slot:*`)读写两侧都过滤。
### 参数
所有查询参数在入口归一化 / 钳制。非法值返回 400(带人话说明)或回落默认,
**绝不 500**——全局 `ValueError` 处理器兜住 `strptime` / `int()` 这类异常。
---
## 八、配置系统:写时校验 + 读时兜底
历史 bug:配置页是自由文本框,把 `page_size` 敲成 `abc` 后,
采集在 `int()` 处抛 `ValueError` 整个跑不起来。现在的双保险:
**写时校验**(`config.normalize_setting`)
```python
NUM_SETTINGS = {"page_size": (20, 1000, "条/页"), ...} # 范围 + 单位
BOOL_SETTINGS = {"schedule_enabled", "catch_up"}
```
非法值 → `400 {"error":"invalid","errors":[...]}`,一次列出**全部**错误,并写审计 `settings_rejected`。
**读时兜底**
```python
page_size = db.get_int(conn, "page_size", 200) # 任何异常都回落默认值
```
采集路径上**不允许出现裸 `int(s.get(...))`**。数值还会按 `NUM_SETTINGS` 的范围再钳一次。
---
## 九、已知坑与红线
### 流式响应里不能复用 `db.get_db()`
Flask 在 `full_dispatch_request()` 返回 `app_iter` **之后**就 pop 请求上下文
(`teardown_appcontext` → `close_db` 关掉 `g.db`),WSGI 服务器**才开始**迭代生成器。
于是生成器一读库就报 `Cannot operate on a closed database`。
**规则**:凡 `Response(gen())` / `stream_with_context` 场景,生成器内部要 `db.connect()`
自建连接并 `finally` 关闭。(`/records/export` 就是这么修的。)
### `send_from_directory` 吐的单层路由,页内资源必须写绝对路径
`/dashboard` 没有尾斜杠,页内 `src="vendor/echarts.min.js"` 会被解析成
`/vendor/echarts.min.js` → 404 → **整页图表全白**。静态挂载点是 `/static`,
所以写 `/static/dashboard/vendor/echarts.min.js`。
这个 bug 曾经躲过「状态码断言」和「真实 HTTP」两层测试,只有浏览器截图才抓到。
现在 `tools/smoke.py` 有专门一节:抓页面里所有 `src`/`href` 资源引用逐个断言 200
(断言前先剥掉 HTML 注释,否则注释里的示例路径会被误判)。
### Jinja 里避开 `dict` 的方法名
模板中 `a.items` / `a.keys` / `a.get` / `a.values` / `a.update` / `a.pop` / `a.copy`
会命中**方法**而不是数据(属性查找优先于下标)。视图里把这类值拆成独立变量传。
同类坑:**视图里不要把 `fetchall()` 的 Row 列表用列表推导扁平化成字符串列表**
——模板写 `a[0]` 会变成「取字符串第一个字符」,**而且不报错**。
### 同一文件不能连续并行编辑
同一轮响应里对同一文件发多个编辑会互相覆盖(都返回成功,只有最后一个落盘)。
改同一文件必须串行,改完回读确认。
### 新增组件用带前缀的独有类名
`class="bar"` 撞上页面已有的筛选条 `.bar`(带 `backdrop-filter: blur(6px)`),
会让整块文字被静默虚化。新组件一律用带前缀的类名(如 `.calcell .cbar`)。
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言兜这类问题。
### 前端日期运算不要用 `toISOString().slice(0,10)`
GMT+8 下 `new Date("2026-08-15T00:00:00")` 的 UTC 时刻是前一天 16:00,
取出来就少一天,「加一天」变成「减一天」,循环跑飞。
用 `getFullYear/getMonth/getDate` 拼本地串。
### ECharts 热力图 `data` 是「一个坐标一个点」
同坐标重复 push 会**互相覆盖而非累加**。要展示格子合计,必须先在 JS 里按 `(x,y)` 聚合再 push。
### 其它
- 传给模板的「带下标的行」直接传 `fetchall()` 的 Row 列表,不要先扁平化。
- 本机 chromium 截图要用同版本二进制初始化过的 profile 目录;
Playwright 驱动与本机浏览器版本会错位,需显式传 `executable_path`。
- `urllib` / `curl` 会走本机代理,把 `127.0.0.1` 也拦成 502——
探测本地服务要 `build_opener(ProxyHandler({}), ...)` 或 `--noproxy '*'`。
---
## 十、验证体系
五层,按代价从低到高。**前两层已固化成脚本,改完必须跑。**
| 层 | 手段 | 抓什么 |
|---|---|---|
| 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 |
| 4 | Node DOM stub + `vm.runInContext` 跑大屏真实脚本 | 「页面聚合 == 独立算出的聚合」、切区间只发一次请求 |
| 5 | `tools/shots.py`(Playwright 截图 + console/pageerror) | **界面层**。本轮最有价值的 bug(大屏全白)只有它抓到 |
```bash
python tools/smoke.py # 1~2 层,随时跑
python manage.py serve --port 8849 --no-scheduler # 另开终端
python tools/check_live.py --base http://127.0.0.1:8849 # 3 层
python tools/shots.py --base http://127.0.0.1:8849 --full # 5 层
```
**改动前先读 [九、已知坑与红线](#九已知坑与红线),改完先把第 2 层跑绿。**
+128
查看文件
@@ -0,0 +1,128 @@
# 变更日志
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/):`主版本.次版本.修订号`。
- **主版本**:不兼容的变更(数据库迁移需手工介入、接口契约变化)
- **次版本**:向后兼容的功能新增
- **修订号**:向后兼容的缺陷修复
---
## [1.1.0] — 2026-09-14
**主题:项目定名 `workbuddy-portal` · 容器化 · 文档体系**
### 新增
- **Docker 化**:多阶段 `Dockerfile`(依赖层与运行层分离,改业务代码不触发重装依赖)、
`docker-compose.yml`(单服务、数据绑定挂载、健康检查、日志轮转)、
`docker/entrypoint.sh`(幂等初始化 → exec 交接)、`docker/healthcheck.py`(纯标准库)、
`.dockerignore`、`.env.example`
- **容器环境变量**:`WB_HOST` `WB_PORT` `WB_DATA_DIR` `WB_LOG_DIR` `WB_DB`
`WB_ADMIN_USER` `WB_ADMIN_PASSWORD` `WB_DISABLE_SCHEDULER` `WB_IMPORT_CREDS` `WB_IMPORT_XLSX`
- **文档体系** `docs/`:
[用户使用手册](USER-GUIDE.md)(含 9 张界面截图)、
[部署与运维指南](DEPLOYMENT.md)(含推镜像到 Gitea 注册表的完整流程)、
[架构与设计说明](ARCHITECTURE.md)、
[接口参考](API.md)、
[常见问题](FAQ.md)
- **`.gitattributes`**:强制 `*.sh` / `Dockerfile` / 各类源码为 LF
(带 CRLF 的 `.sh` 在容器里会报 `exec format error`,极难定位)
### 变更
- **项目定名**:`wb_usage_portal` → **`workbuddy-portal`**;
Python 包 `wb_usage` → **`workbuddy_portal`**;会话 cookie
`wb_usage_sid` → `workbuddy_portal_sid`(升级后需要重新登录)
- **界面品牌**统一为 **WorkBuddy Portal**(此前为「WorkBuddy 用量门户」)
- 项目标识收敛到 `config.PROJECT_NAME` / `PROJECT_TITLE` / `PROJECT_DESC` 单一来源,
模板通过 `app_name` 等上下文变量引用,不再多处硬编码
- 数据 / 日志目录支持环境变量覆盖(`WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB`)
- 版本号 1.0.0 → **1.1.0**
- 大屏页标题改为「WorkBuddy Portal · 积分消耗大屏」
### 修复
| # | 症状 | 根因 |
|---|---|---|
| 1 | `/records/export` **必然 500** | 生成器在请求上下文销毁后才被迭代,复用 `db.get_db()` 撞「数据库已关闭」。改为生成器内自建连接 |
| 2 | **大屏页图表全白** | `/dashboard` 无尾斜杠,`src="vendor/echarts.min.js"` 被解析成 `/vendor/…` → 404 |
| 3 | `/users` 500 | 路由已注册但 `users.html` 不存在 |
| 4 | 明细页日期筛选失效 | 视图传 `f.frm`、模板读 `f.from`;导出链接拼 `?frm=` 而接口只认 `from` |
| 5 | 配置页 3 个维护按钮全死 | 模板调 `WBU.bindMaint()`,`app.js` 里没有该函数 |
| 6 | 审计只能看最近 40 条 | `LIMIT 40` 写死 |
| 7 | 明细页多跑一条无用 `SELECT` | `day_list()` 取了没人用 |
| 8 | 登录页锁定阈值写死 | 未从配置注入 |
> **#2 值得单独一提**:前两层测试都没抓到(离线断言只看状态码 + `<html>`,
> 真实 HTTP 只看状态码),是加上 Playwright 截图后才发现的。
> 据此给 `tools/smoke.py` 补了「页面所有 `src`/`href` 资源引用逐个断言 200」一节。
### 安全
- 新增 `security.safe_next()`:登录跳转的 `next` 拒绝 `//evil.com`(协议相对 URL)、
`/\evil.com`、绝对地址与含 CR/LF 的值
- 缺 CSRF 的写请求统一 400(此前部分路径漏检)
- 默认**开启**云端 HTTPS 证书校验(`ssl_verify=1`)。Cookie 是账号凭证,不该裸奔
- 登录失败计数表加上限(4096 个 IP)与 TTL(1 小时),防内存被大量来源 IP 撑爆
- `/logout` 拆分为 POST(执行)+ GET(仅提示),防 `<img src="/logout">` 静默退出
- `settings` 的内部簿记键(`slot:*`)读写两侧都过滤,不再从 `/api/settings` 泄漏
### 内部质量
- 设置项**写时校验 + 读时兜底**:`config.normalize_setting()`(范围 + 单位,非法值 400
并列出全部错误)+ 采集路径全面改用 `db.get_int()`,杜绝「一个手滑的数字让采集整个跑不起来」
- 全局 `ValueError` → 400 处理器:手写 query string 不再能把 500 页面暴露出去
- 日期归一化 `norm_day()` / `norm_window()`(容忍 `2026/09/08`、带时间、起止写反)
- CSV 导出改用 `csv.writer` 流式写入(此前手工拼字符串,`model`/`client` 含逗号会串列)
- `bundle` 明细加下限(`BUNDLE_RECORDS_CAP = 20000`),并返回
`recordsTotal` / `recordsCap` / `recordsTruncated`,不静默丢数据
- 轮询日志尾部改用 `collections.deque(maxlen=n)`,不再把整个文件读进内存
- 修掉一条非法 CSS 声明 `font: 13px/1.5 inherit`(简写里 `inherit` 不能当字族,
整条被浏览器丢弃,输入框一直用默认字体)
### 工具
- 新增 `tools/smoke.py`:**离线回归 99 项断言**(全页面只读渲染 + 模板残留 +
历史缺陷防回归 ①~⑭ + 静态资源逐个 200 + CSV 列 + 页面 class ↔ `app.css` 选择器对账),
不需要先起服务
- `tools/check_live.py` 扩充到 **56 项断言**,新增用户管理 / 审计 / 流式导出 /
开放重定向 / CSRF 四节
- 新增 `tools/shots.py`:Playwright 登录后逐页截图 + 收集 console / pageerror
(内建可执行文件探测,规避驱动与本机浏览器版本错位)
---
## [1.0.0] — 2026-09-14
**主题:从「脚本 + CSV + 静态大屏」演化为独立可部署的门户**
### 新增
- 独立 Flask 应用:`create_app` 工厂 + `views` / `api` 双蓝图
- SQLite(WAL)作为**唯一数据正本**,主键去重、断点续采、聚合全部下推 SQL
- 进程内调度线程(20 秒轮询 + 槽位去重 + 启动补跑),**不再依赖外部计划任务**
- 单写者文件锁 `data/collect.lock`(含 30 分钟僵尸锁抢占)
- 登录鉴权(`pbkdf2:sha256:200000`)、全站 CSRF、同 IP 失败限速
- 后台页面:概览 / 数据明细 / 任务管理 / 配置管理 / 日志管理
- 独立 ECharts 交互大屏 `/dashboard`(离线自带 echarts,不依赖 CDN)
- CLI:`init` `serve` `collect` `migrate-csv` `import-xlsx` `import-creds`
`fill-prompt` `export-csv` `stats` `status` `passwd` `vacuum`
- 从旧版 CSV / 官网 xlsx / 编辑器设置导入的迁移通道
- 从 VSCode / Cursor / Trae 的 `settings.json` 接管 Cookie 与 User-Agent
### 变更
- 数据正本从 CSV 改为 SQLite;CSV 降级为导出物(`data/exports/`)
- 采集从外部脚本改入 Web 进程;删除全部外部自动化与计划任务
- 运行期配置(Cookie、调度、采集参数)从文件搬进数据库,由后台页面维护
---
## 版本对照
| 版本 | 数据正本 | 调度 | 部署 | 鉴权 |
|---|---|---|---|---|
| 1.1.0 | SQLite(WAL) | 进程内 | Docker Compose / 裸机 | 登录 + CSRF + 角色 |
| 1.0.0 | SQLite(WAL) | 进程内 | 裸机 | 登录 + CSRF |
| < 1.0 | CSV 文件 | 外部计划任务 | 脚本 | 无(仅局域网) |
+475
查看文件
@@ -0,0 +1,475 @@
# 部署与运维指南
> 面向**运维 / 部署者**。从零到跑起来,以及跑起来之后的备份、升级、排错。
**目录**
- [一、部署方式怎么选](#一部署方式怎么选)
- [二、Docker Compose 部署](#二docker-compose-部署)
- [三、裸机部署](#三裸机部署)
- [四、反向代理与 HTTPS](#四反向代理与-https)
- [五、把镜像推到 Gitea 注册表](#五把镜像推到-gitea-注册表)
- [六、备份与恢复](#六备份与恢复)
- [七、升级与回滚](#七升级与回滚)
- [八、日常巡检](#八日常巡检)
- [九、排错](#九排错)
- [十、配置项速查](#十配置项速查)
---
## 一、部署方式怎么选
| 场景 | 建议 |
|---|---|
| 有 Docker(NAS / 服务器 / 本机 Docker Desktop) | **Docker Compose**,最省事,升级只需换镜像 |
| 不想装 Docker,或要跑在 Windows 上用系统计划任务兜底 | 裸机 Python + `waitress` |
| 想给多人访问 | 任一种方式 + 反向代理(加 HTTPS 更稳) |
> **不要横向扩展**。SQLite 是单写者,调度线程也在 Web 进程内,
> 多副本只会带来锁竞争和重复采集。这个服务天然是单实例的。
---
## 二、Docker Compose 部署
### 2.1 前置
- Docker Engine 20.10+ / Docker Desktop(含 Compose v2)
- 至少 200 MB 磁盘(镜像 152 MB + 数据)
### 2.2 步骤
```bash
git clone http://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_DISABLE_SCHEDULER` | `0` | `1` = 不启动调度线程(只跑手动采集) |
| `WB_IMPORT_CREDS` | `0` | `1` = 启动时尝试从挂载的编辑器配置导入 Cookie |
### 2.4 数据落点
| 容器内 | 宿主机 | 内容 |
|---|---|---|
| `/app/data` | `./data` | `usage.sqlite`(正本)、`instance.json`(secret_key)、`exports/` |
| `/app/logs` | `./logs` | `app.log`(滚动 2 MB × 3) |
**绑定挂载**而非命名卷,是为了:备份就是拷目录;宿主机上的 `manage.py` 能直接读同一份数据。
> **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 流式下载都可用。
---
## 三、裸机部署
### 3.1 Windows
```bat
git clone http://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 http://git.iwali.top/wangchuanli/workbuddy-portal.git
cd workbuddy-portal
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python manage.py init --user admin --password 你的强密码
```
`/etc/systemd/system/workbuddy-portal.service`:
```ini
[Unit]
Description=WorkBuddy Portal
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=workbuddy
WorkingDirectory=/opt/workbuddy-portal
Environment=TZ=Asia/Shanghai
ExecStart=/opt/workbuddy-portal/.venv/bin/python manage.py serve --host 0.0.0.0 --port 8848
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now workbuddy-portal
sudo systemctl status workbuddy-portal
journalctl -u workbuddy-portal -f
```
> **不要**用 `manage.py collect` + cron 替代内置调度,除非你确实想让调度留在外部
> (那种情况下 Web 端要加 `--no-scheduler`,避免和 cron 抢锁——虽然文件锁会保证正确性,
> 但会白跑一次)。
---
## 四、反向代理与 HTTPS
前面挂 nginx 时要注意两点,否则会踩坑:
```nginx
server {
listen 443 ssl;
server_name portal.example.com;
ssl_certificate /etc/ssl/certs/portal.crt;
ssl_certificate_key /etc/ssl/private/portal.key;
location / {
proxy_pass http://127.0.0.1:8848;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 必须透传:登录失败限速按真实 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; # 采集/导出可能跑几分钟
}
}
```
排错要点:
| 现象 | 原因 |
|---|---|
| 登录限速「误伤」所有人 | 没透传 `X-Forwarded-For` |
| 导出 CSV 要等很久才出第一个字节 | 没关 `proxy_buffering` |
| 手动采集走到 504 | `proxy_read_timeout` 太短 |
---
## 五、把镜像推到 Gitea 注册表
Gitea 自带容器注册表(`registry/2.0`)。目标是 `git.iwali.top/wangchuanli/workbuddy-portal`。
### 5.1 准备:本机允许 HTTP 注册表
Gitea 走的是 **HTTP**,Docker 默认只允许 HTTPS。Docker Desktop:**Settings → Docker Engine**,
在 `daemon.json` 里加:
```json
{
"insecure-registries": ["git.iwali.top"]
}
```
`Apply & Restart`。Linux 上同理改 `/etc/docker/daemon.json` 后 `sudo systemctl restart docker`。
> 这是**内网自托管服务**的常规做法。若 Gitea 前面有带证书的 Caddy/nginx,
> 用 `https://` 地址即可,不必开 insecure。
### 5.2 登录
```bash
docker login git.iwali.top -u wangchuanli
# 密码用 Personal Access Token(Gitea「设置 → 应用 → 生成令牌」,
# 勾选 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
docker push git.iwali.top/wangchuanli/workbuddy-portal:latest
docker push git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
```
### 5.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
volumes:
- ./data:/app/data
- ./logs:/app/logs
YAML
docker compose up -d
```
### 5.5 验证远端
```bash
docker manifest inspect git.iwali.top/wangchuanli/workbuddy-portal:1.1.0
# 或
curl -s -u wangchuanli:TOKEN \
http://git.iwali.top/api/v1/packages/wangchuanli?type=container
```
---
## 六、备份与恢复
### 6.1 备份什么
| 文件 | 重要性 | 说明 |
|---|---|---|
| `data/usage.sqlite` | ★★★ | **数据正本**,丢了要重新采集,且官网窗口外的数据永久丢失 |
| `data/usage.sqlite-wal` / `-shm` | ★★★ | WAL 模式下未 checkpoint 的数据在这里,**要一起拷** |
| `data/instance.json` | ★★ | 含 `secret_key`,丢了所有人都要重新登录(数据不受影响) |
| `data/exports/*.csv` | ★ | 导出快照,可再生 |
| `logs/` | ☆ | 排错用,可再生 |
`.env` 不在里面——它含密码,**单独用密码管理器保管**。
### 6.2 备份命令
```bash
# 方式 A:先 checkpoint 再拷(推荐,最干净)
docker compose exec portal python -c "
from workbuddy_portal import db
c = db.connect(); c.execute('PRAGMA wal_checkpoint(TRUNCATE)')
"
# Windows 宿主机
copy data\usage.sqlite D:\backup\usage-%DATE%.sqlite
# Linux 宿主机
cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite
# 方式 B:直接整体拷(含 -wal / -shm)
docker compose stop portal
tar czf backup-$(date +%F).tar.gz data/
docker compose start portal
# 方式 C:逻辑导出(跨版本最安全)
docker compose exec portal python manage.py export-csv /app/data/exports
```
建议方式 A 配合计划任务每天跑一次;每季度用方式 C 出一份逻辑快照。
### 6.3 恢复
```bash
docker compose down
cp ~/backup/usage-2026-09-14.sqlite data/usage.sqlite
rm -f data/usage.sqlite-wal data/usage.sqlite-shm # 关键:清掉旧 WAL
docker compose up -d
docker compose exec portal python manage.py stats # 核对条数
```
> **别把旧库和旧 WAL 混着用**。WAL 里记的是相对旧库的增量,配错会损坏数据。
---
## 七、升级与回滚
### 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
```
### 裸机
```bash
git pull
.venv/bin/pip install -r requirements.txt
sudo systemctl restart workbuddy-portal
```
### 升级前
1. **先备份**(见第六节)——`schema.sql` 用的是 `CREATE TABLE IF NOT EXISTS`,
加表加索引是安全的,但改列需要手工迁移,所以备份是唯一保险。
2. 看一眼 [CHANGELOG](CHANGELOG.md) 有没有破坏性变更。
---
## 八、日常巡检
| 频率 | 做什么 |
|---|---|
| 每天 | 打开「概览」看「采集健康」;确认今天有采集记录 |
| 每周 | 「日志管理」按 `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
```
---
## 九、排错
### 容器起来了但页面打不开
```bash
docker compose ps # 看 STATUS 是否 (healthy)
docker compose logs --tail=100
```
| 症状 | 排查 |
|---|---|
| `STATUS` 是 `Restarting` | 看日志里的 Python traceback;多半是 `data/` 权限或端口冲突 |
| `unhealthy` 但 `Up` | 健康检查打 `/login` 失败;`docker compose exec portal python /app/docker/healthcheck.py` 看具体报错 |
| 端口占用 | 改 `.env` 的 `WB_PORT`,如 `18848:8848` |
| 宿主机能访问、局域网不能 | `WB_BIND` 是不是被改成 `127.0.0.1` 了;防火墙有没有放行 |
### `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'))"
```
### 数据库相关
| 报错 | 原因 / 处理 |
|---|---|
| `unable to open database file` | 目录属主不对:`sudo chown -R 1000:1000 ./data` |
| `database is locked` | 有另一个写进程(另一个容器?宿主机上的 CLI?);等它跑完 |
| `disk I/O error` | 挂载文件系统不支持 SQLite 的锁语义。改用本地盘或 Docker 命名卷 |
### 采集相关
| 报错 | 处理 |
|---|---|
| `cookie_expired` / `401` | 重新获取 Cookie 填进「配置管理」,然后按区间补采 |
| `TLS` / `SSLError` | 企业代理 / 自签证书场景,临时把 `ssl_verify` 设为 `0`;否则保持开启 |
| 返回 `409 busy` | 正常——已有采集在跑,等它结束 |
| 新增一直是 0 | 看「抓取」条数:>0 说明都是已存在的(正常);=0 说明云端该时段确实没数据 |
### 时区不对导致日期错位
```bash
docker compose exec portal date # 应该输出 CST / +0800
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,改完 `docker compose up -d` 重建容器
(`TZ` 是环境变量,`restart` 不生效)。
### 想临时关掉自动采集
「任务管理 → 取消勾选『启用调度』→ 保存」。或者
```bash
echo "WB_DISABLE_SCHEDULER=1" >> .env && docker compose up -d
```
---
## 十、配置项速查
调度与采集参数都在数据库里,**改完立即生效、不用重启**(页面「任务管理 / 配置管理」可改,
也可以直接改表):
| 键 | 默认 | 说明 |
|---|---|---|
| `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 同源更稳 |
**写错的值会在保存时被拒绝**并给出原因,不会污染配置。
环境变量(启动期,改了要重建容器):
| 变量 | 说明 |
|---|---|
| `TZ` | 时区,影响所有日期口径 |
| `WB_HOST` / `WB_PORT` | 容器内监听地址 / 端口 |
| `WB_DATA_DIR` / `WB_LOG_DIR` / `WB_DB` | 数据 / 日志 / 库文件路径覆盖 |
| `WB_DISABLE_SCHEDULER` | `1` = 不启动调度线程 |
| `WB_ADMIN_USER` / `WB_ADMIN_PASSWORD` | 首个管理员(仅库为空时生效) |
| `WB_IMPORT_CREDS` / `WB_IMPORT_XLSX` | 启动时自动导入 |
+285
查看文件
@@ -0,0 +1,285 @@
# 常见问题(FAQ)
按「现象」分类。每条都给出**原因**和**动作**,不用从头排查。
---
## 一、部署
### Q:`docker compose up` 报端口被占用
```
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:8848
```
改 `.env` 里的 `WB_PORT`,比如 `WB_PORT=18848`,然后 `docker compose up -d`。
容器内始终监听 8848,只改宿主映射即可。
### Q:容器起来了但局域网访问不了
1. `.env` 的 `WB_BIND` 是不是被设成了 `127.0.0.1`(那只允许本机);
2. 服务器防火墙有没有放行该端口;
3. `docker compose ps` 的 `PORTS` 是不是 `0.0.0.0:8848->8848/tcp`。
### Q:容器 `unhealthy` 但 `Up`
```bash
docker compose exec portal python /app/docker/healthcheck.py
```
健康检查打的是 `/login`(唯一免登录页),拿到 200 才算健康。
若失败,看 `docker compose logs` 有没有 Python traceback。
### Q:`unable to open database file`
Linux 宿主机上宿主目录属主与容器内 uid 1000 不一致:
```bash
sudo chown -R 1000:1000 ./data ./logs
docker compose restart
```
### Q:`database is locked` / `disk I/O error`
| 报错 | 原因 |
|---|---|
| `database is locked` | 有另一个写者(另一个容器实例?宿主机上同时在跑 `manage.py collect`?)。等它结束——文件锁会串行化,但 SQLite 层面的写冲突仍会短暂报错 |
| `disk I/O error` | 挂载的文件系统不支持 SQLite 需要的锁语义(某些 NFS / 网络盘)。改用本地盘或 Docker 命名卷 |
### Q:`exec format error` 或 `no such file or directory`(关于 entrypoint.sh)
`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'))"
```
### Q:能不能跑多个副本做高可用
**不要。** 三个理由:SQLite 是单写者;调度线程在 Web 进程内;文件锁只在本机有效。
真要跑多副本,除第一份外全部设 `WB_DISABLE_SCHEDULER=1`,但写冲突依然存在。
这个服务的正确扩展方式是「升级到更强的单机」,不是横向加副本。
---
## 二、采集
### Q:采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新获取(见 [用户手册 3.2](USER-GUIDE.md#32-拿-cookie-的两种办法)),
填进「配置管理 → 凭证」,保存后按区间补采。
> Cookie 通常是浏览器会话级,**关掉浏览器可能就失效**。从已登录浏览器复制时勾选「保持登录」。
### Q:采集成功但「新增 0 条」
大概率正常。看那一次的 `抓取` 条数:
| 抓取 | 新增 | 判断 |
|---|---|---|
| > 0 | 0 | 云端返回的都是库里已有的(断点回退窗口重叠)——**正常** |
| 0 | 0 | 该时段云端确实没有记录——**正常** |
| > 0 | > 0 | 正常采集到新数据 |
### Q:返回 409 `busy`
已有采集在跑。文件锁 `data/collect.lock` 保证同时只有一个采集。
到「任务管理 → 运行历史」看它是否还在 `running`,等结束再操作。
### Q:TLS / SSLError
企业代理或自签证书场景。两种处理:
1. **推荐**:把企业根证书装进系统信任链;
2. **临时**:「配置管理」把 `ssl_verify` 设为 `0`。
> Cookie 就是账号凭证,关掉证书校验等于把它暴露在中间人面前。**只在受控内网临时用。**
### Q:日志里出现「云端时间比本地早」告警
云端记录的 `cloud_ts` 比本地 `ts` 早超过 `drift_tolerance_minutes`(默认 5 分钟)。
影响不大,但可能意味着:
- 服务端时钟不准 → 校时;
- 云端写入有延迟 → 适当调大 `rewind_minutes`(比如 5)。
### Q:想让采集更频繁 / 更稀疏
「任务管理 → 每日时刻」改成任意逗号分隔的时刻,如 `08:30,12:30,18:00,22:00`。
保存即生效,不用重启。时刻按**本地时区**解释。
---
## 三、数据与日期
### Q:日期差一天 / 跨日数据落到相邻日期
时区问题。所有日期按服务端本地时区计算。
```bash
docker compose exec portal date # 期望:CST / +0800
docker compose exec portal python -c "from workbuddy_portal import db; print(db.now_str())"
```
若不是,检查 `.env` 的 `TZ=Asia/Shanghai`,然后 **`docker compose up -d` 重建**
(`TZ` 是环境变量,`restart` 不生效)。
### Q:导出的 CSV 在 Excel 里乱码
本系统导出的文件带 **UTF-8 BOM**,双击不会乱码。若乱码,先确认你打开的是
从「导出 CSV」拿到的文件,而不是用记事本另存过的版本。
### Q:大屏的「单笔 TOP」为什么切了日期区间也不变
**设计如此。** `top` 是**全局**的:如果跟着窗口变,排名会随筛选跳动,反而看不出长期最贵的那几条。
想要窗口内的排行,用「数据明细」按积分降序 + 日期筛选。
### Q:大屏的 `daily`(日历/趋势)为什么不受区间影响
也是设计如此:`daily` 是全量(约 200 B/天),日历与日期轴需要完整日期序列。
真正跟随窗口的是 `dims` / `totals` / 明细表。
### Q:明细表提示「只显示最近 N 条」
大屏下发的明细有上限(`recordsCap = 20000`)。超过时只发**最新 N 条**并置
`recordsTruncated=true`,页面据此提示。完整数据请到「数据明细」页筛选或导出。
---
## 四、界面
### Q:页面能开但图表全白
1. `Ctrl+F5` 强刷清缓存;
2. 开浏览器控制台看有没有资源 404;
3. 「日志管理 → 应用日志」看有没有异常栈。
> 历史上有过 `/dashboard` 下相对路径把 `echarts.min.js` 解析成 `/vendor/...` 导致
> 整页全白的问题,已在 `tools/smoke.py` 里加了「页面所有 `src`/`href` 资源逐个断言 200」防回归。
### Q:某块样式突然失效 / 文字发虚
多半是类名撞了全局样式。本项目约定:**新组件用带前缀的独有类名**
(如 `.calcell .cbar`,而不是含糊的 `.bar`)。
`smoke.py` 里有「页面 class ∩ `app.css` 选择器」差集断言,跑一遍就能发现异常类名。
### Q:登录后跳转目标丢了
历史 bug,已修。现在 `next` 参数在 GET/POST 两条路径上都正确回填。
注意开放重定向防护会拒绝站外目标:`//evil.com`、`/\evil.com`、`https://evil.com`
一律回落到 `/`——这是**预期行为**。
---
## 五、账号与权限
### Q:忘记管理员密码
```bash
# 裸机
python manage.py passwd admin 新密码
# Docker
docker compose exec portal python manage.py passwd admin 新密码
```
不传新密码时会用默认的 `admin123`——**别这么干**。
### Q:怎么给同事开只读账号
「用户管理 → 新建账号」,**不要勾**「管理员」。
普通用户能看所有页面、能导出、能触发采集,但看不到「用户管理」且访问 `/users` 返回 403。
### Q:不小心把自己降级 / 删掉自己了
做不到。服务端有三条护栏:不能取消自己的管理员身份、不能删除自己、至少保留一个账号。
### Q:所有人被踢下线了
`SECRET_KEY` 变了。它存在 `data/instance.json`。这个文件丢了/被删了就会重新生成,
所有会话失效(**数据不受影响**)。恢复办法:从备份里找回 `instance.json`,或让大家重新登录。
---
## 六、运维
### Q:备份怎么做最稳
```bash
# 1) 先 checkpoint,把 WAL 落进主库
docker compose exec portal python -c "
from workbuddy_portal import db
db.connect().execute('PRAGMA wal_checkpoint(TRUNCATE)')"
# 2) 拷走
cp data/usage.sqlite ~/backup/usage-$(date +%F).sqlite
```
或者整体 `tar` 掉 `data/`(含 `-wal` / `-shm`)——**别把新库配旧 WAL 用**,那会损坏数据。
### Q:数据库文件越来越大
```bash
docker compose exec portal python manage.py vacuum
```
或在「配置管理 → 维护动作 → 整理数据库」点一下。
作用是 `wal_checkpoint(TRUNCATE)` + `VACUUM`,回收删除后的空闲页并压缩 WAL。
### Q:升级会不会丢数据
不会。数据在宿主机的 `data/`(绑定挂载),`docker compose up -d --build` 只重建容器。
但**升级前依然要备份**:`schema.sql` 用 `CREATE TABLE IF NOT EXISTS`,
加表加索引安全,**改列需要手工迁移**。
### Q:日志在哪、怎么滚动
| 位置 | 内容 |
|---|---|
| `logs/app.log`(挂载到宿主机) | 应用日志,滚动 2 MB × 3 |
| `docker compose logs` | 容器 stdout(entrypoint + waitress) |
| 页面「日志管理」 | 采集逐行日志 + 应用日志尾部 + 操作审计 |
### Q:想改采集的接口地址(走镜像/代理)
「配置管理」里改 `api_base` 与 `api_path`。改了之后记得同步确认 Cookie 是该域下的有效凭证。
---
## 七、开发
### Q:改完代码怎么验证
**五层,前两层必须跑绿**:
```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 报错
```
详见 [架构说明 · 验证体系](ARCHITECTURE.md#十验证体系)。
### Q:`ModuleNotFoundError: No module named 'flask'`
选错解释器了。依赖装在项目的 venv 或托管环境里:
```bash
python -c "import flask, sys; print(sys.executable, flask.__version__)"
```
报这个错说明当前 `python` 不是装了依赖的那个。
### Q:改模板后页面没变
本项目 `TEMPLATES_AUTO_RELOAD=True`,模板改动通常立即生效。
若是静态资源(CSS/JS)被浏览器缓存,`Ctrl+F5`。
### Q:写了个自定义接口结果 500,但日志只看到异常栈
先看是不是**流式响应**(`Response(gen())` / `stream_with_context`):
Flask 在返回 `app_iter` 之后就关掉了请求上下文里的连接,
生成器里若复用 `db.get_db()` 会报 `Cannot operate on a closed database`。
正确做法是在生成器内部 `db.connect()` 自建连接并 `finally` 关闭。
详见 [架构说明 · 已知坑](ARCHITECTURE.md#九已知坑与红线)。
+413
查看文件
@@ -0,0 +1,413 @@
# WorkBuddy Portal 用户使用手册
> 面向**使用者**(不是开发者)。读完这份就能独立完成日常操作:
> 登录 → 看用量 → 配置采集 → 查明细 → 导数据 → 处理常见异常。
**目录**
- [一、这个系统是做什么的](#一这个系统是做什么的)
- [二、登录与账号](#二登录与账号)
- [三、获取并填写 Cookie](#三获取并填写-cookie)
- [四、概览页:一眼看清家底](#四概览页一眼看清家底)
- [五、用量大屏:交互式分析](#五用量大屏交互式分析)
- [六、数据明细页:查、筛、导](#六数据明细页查筛导)
- [七、任务管理页:定时与补采](#七任务管理页定时与补采)
- [八、配置管理页:参数与维护](#八配置管理页参数与维护)
- [九、日志管理页:出问题先看这里](#九日志管理页出问题先看这里)
- [十、用户管理页(仅管理员)](#十用户管理页仅管理员)
- [十一、常见任务速查](#十一常见任务速查)
- [十二、常见问题](#十二常见问题)
---
## 一、这个系统是做什么的
它把 WorkBuddy 账号的**积分用量明细**自动采集下来,存成一份**永久全量存档**,并提供查询与可视化。
为什么不直接看官网?官网只给一段时间窗口的明细,过期就查不到了;导出的 xlsx 还会丢掉
约 22% 的 `User Prompt` 内容。本系统把数据落到自己的库里,**只增不减**,随时能翻旧账。
一次典型的日常是:
```
每天 09:00 / 17:00 系统自动采集(你什么都不用做)
↓
你想看看进度 → 打开「概览」看今天用了多少
想深挖 → 打开「用量大屏」按模型/客户端/时段切
要找某条记录 → 「数据明细」搜索 + 展开 Prompt
要拿给别人 → 「数据明细」→ 导出 CSV
```
---
## 二、登录与账号
打开 `http://<部署机器IP>:8848`,会看到登录页。
![登录页](images/00-login.png)
| 项目 | 说明 |
|---|---|
| 默认账号 | `admin` / `admin123`(**只有数据库里一个账号都没有时**才会创建) |
| 登录保持 | 12 小时 |
| 失败限制 | 同一 IP 连续错 5 次,锁定 10 分钟 |
| 退出 | 右上角「退出」(走 POST,防被恶意链接静默触发) |
> ⚠️ **首次部署请立刻改密码**:系统是给局域网访问的,默认密码等于没锁门。
> 改法:「配置管理 → 修改密码」,或命令行 `python manage.py passwd admin 新密码`。
### 权限差别
| 能力 | 管理员 | 普通用户 |
|---|---|---|
| 看概览 / 大屏 / 明细 / 任务 / 配置 / 日志 | ✅ | ✅ |
| 手动触发采集、补采、改配置 | ✅ | ✅ |
| 导出 CSV | ✅ | ✅ |
| **用户管理**(建号 / 改权限 / 删号) | ✅ | ❌(导航里不显示,直接访问返回 403) |
> 给只读同事发普通账号即可,没必要共用管理员。
---
## 三、获取并填写 Cookie
**没有 Cookie,采集一定失败。** 这是首次部署唯一的必要手工步骤。
### 3.1 为什么要 Cookie
采集是直接调账号的用量接口,云端用 Cookie 认人。Cookie 是账号凭证,所以它:
- 存在数据库里,页面上**只回显掩码**(如 `a1b2…f9`);
- 不会被任何接口以明文返回。
### 3.2 拿 Cookie 的两种办法
**办法 A:让程序自己从编辑器设置里读(最省事)**
如果你平时用 VSCode / Cursor / Trae 登录过 WorkBuddy,Cookie 已经在本机设置里:
```bash
python manage.py import-creds
```
它会去读编辑器 `settings.json` 里的 `codebuddyUsage.*` 字段,写进数据库。
Docker 部署时对应 `WB_IMPORT_CREDS=1`(需要把设置文件挂进容器)。
**办法 B:手工复制(一定可行)**
1. 浏览器打开并登录 WorkBuddy 官网;
2. 按 `F12` 打开开发者工具 → 切到 **Network(网络)** 标签;
3. 刷新页面,随便点一个发往 `workbuddy.cn` 的请求;
4. 在 **Request Headers(请求标头)** 里找到 `Cookie:` 一行;
5. **整行值**复制下来(很长,通常几千字符,要复制完整);
6. 到本系统「配置管理 → 凭证 → Cookie」,粘贴,保存。
> 顺手把 User-Agent 也填成同一个浏览器的 UA,成功率高一些。
### 3.3 验证 Cookie 是否有效
保存后到「任务管理 → 立即采集一次」,然后看「日志管理」最新一条:
| 日志里看到 | 含义 | 怎么办 |
|---|---|---|
| `新增 N 条` 或 `无新增(已是最新)` | ✅ 正常 | — |
| `cookie_expired` / `401` / `403` | Cookie 过期了 | 重新执行 3.2 |
| `TLS` / `SSLError` | 证书校验失败 | 见「十二、常见问题」 |
---
## 四、概览页:一眼看清家底
![概览页](images/01-overview.png)
从上到下四块:
**1. KPI 卡片(6 个)**
| 卡片 | 含义 |
|---|---|
| 存档总量 | 库里一共多少条记录(只增不减) |
| 累计积分 | 全部记录的积分合计 |
| 活跃天数 | 有记录的自然日数量 |
| 今日积分 | 今天(本地时区)已消耗 |
| 今日 vs 昨日 | 今日与**昨日整日**对比,带涨跌幅 |
| 采集健康 | 最近若干次采集的成功 / 失败情况 |
**2. 今日 vs 昨日整日**
注意「昨日」是**完整一天**,而「今日」还在进行中——上午看数字偏低是正常的,
该跟昨天的**同一时段**比才有意义(大屏页能做这个对比)。
**3. 调度状态**
显示调度开关、下次执行时刻、上次采集结果。这里显示「已停用」时采集不会自动跑,
到「任务管理」把调度打开。
**4. 模型 TOP + 最近采集**
按模型看积分消耗排行;下方是最近几次采集的触发方式(手动 / 调度 / 启动补跑)、
耗时、抓取条数、新增条数、去重条数。
---
## 五、用量大屏:交互式分析
左侧导航点「**用量大屏**」,或直接访问 `/dashboard`。
![大屏首页](images/07-dashboard.png)
大屏是独立的交互页,顶部可切**时间区间**(今日 / 近 7 天 / 近 30 天 / 自定义),
所有图表联动重绘。主要图表:
| 图 | 看什么 |
|---|---|
| 日历热力图 | 哪几天用得猛(颜色越深越多)。**注意:格子颜色是「该日合计」**,不是单条 |
| 趋势折线 | 按天的积分走势,判断是否在加速 |
| 维度分布 | 按**模型**或**客户端**拆分的占比 |
| 时段分布 | 24 小时里集中在哪些时段(配合判断是否有脚本在跑) |
| 单笔 TOP | 最贵的单次请求,含 Prompt 摘要——最值得优化成本的地方 |
![大屏交互](images/08-dashboard-interact.png)
> 页面左上角有「← 返回后台」等入口,随时能回管理后台。
> 大屏的数据是**按当前筛选窗口实时取**的,不是预生成的静态图——切区间会重新请求。
**怎么用它省钱**:先看「单笔 TOP」抓出最贵的请求类型,再看「时段分布」判断是不是
某个自动化任务在固定时间跑,最后用「数据明细」把那一批记录导出来逐条分析。
---
## 六、数据明细页:查、筛、导
![数据明细页](images/02-records.png)
### 6.1 筛选条件
| 条件 | 说明 |
|---|---|
| 快捷区间 | 「今日 / 近 7 天 / 近 30 天 / 全部」一键填日期 |
| 日期 | `起` / `止`,留空表示不限 |
| 模型 | 下拉,来自库里实际出现过的模型 |
| 客户端 | 下拉,来源客户端标识 |
| 关键词 | 在 `Prompt` 正文里模糊匹配 |
| 每页条数 | 20 ~ 500 |
| 排序 | 时间倒序 / 正序、积分从高到低等 |
> 日期写错格式(如 `abc`、`2026-13-99`)不会白屏,系统会忽略非法值并提示。
### 6.2 看单条的完整 Prompt
列表默认**不显示 Prompt 全文**(它占数据体积约 80%)。点行首的「展开」看该条的完整 Prompt。
想批量看就导出 CSV。
### 6.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)
### 7.1 调度设置
| 项 | 说明 |
|---|---|
| 启用调度 | 总开关。关掉后只有手动采集会跑 |
| 每日时刻 | 逗号分隔的本地时刻,如 `09:00,17:00`。**保存即生效,不用重启** |
| 启动补跑 | 打开后,程序启动时会把今天已错过、且还在宽限期内的时刻补采一次 |
| 补跑宽限期 | 超过多少小时就不补了(默认 12 小时) |
> 调度线程在 Web 进程内,所以「关掉 Web」等于「关掉调度」。
> 如果偶尔忘了开机,靠「启动补跑」把错过的时刻补回来。
### 7.2 手动采集
- **立即采集一次**:按断点续采,最常用的按钮。
- **按区间补采**:填 `起` / `止`,把这几天重新扫一遍。
用途:换了 Cookie 之后回补漏掉的日期;或怀疑某天数据不全时重扫。
重扫**不会产生重复**——主键去重,已存在的记录按「更早的本地时间」保留。
> **同一时刻只能有一个采集在跑**。重复点击会返回「忙碌」提示,这是设计如此
> (SQLite 是单写者,并发只会互相拖慢)。等它跑完再点。
### 7.3 运行历史
每次采集都留一条记录:触发方式、状态、耗时、抓取/新增/去重条数、退出码。
点「详情」看这一次的**逐行日志原文**,包括 `[warn]` 和 `[error]`。
---
## 八、配置管理页:参数与维护
![配置管理页](images/04-config.png)
### 8.1 凭证
| 字段 | 说明 |
|---|---|
| Cookie | 采集用的账号凭证。**只回显掩码**;留空保存 = 不修改(不会被清空) |
| User-Agent | 与拿 Cookie 的浏览器保持一致更稳 |
### 8.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 | 0 ~ 20000 | `Prompt` 入库截断长度,`0` = 不截断 |
| `verify_days` | 0 | 0 ~ 90 | 每次采集后做整日完整性校验的天数,`0` = 关 |
| `timeout` | 30 | 5 ~ 300 | 单次 HTTP 超时(秒) |
| `ssl_verify` | 1(开) | — | 校验云端 HTTPS 证书。**只在自签/企业代理场景才关** |
> **写错的值会被当场拒绝**并提示原因,不会污染配置(历史版本会因为一个手滑的数字
> 让采集整个跑不起来)。范围外的数、非数字都会在保存时被拦下。
### 8.3 维护动作
| 按钮 | 作用 | 何时用 |
|---|---|---|
| 补全 Prompt | 把缺失的 `Prompt` 从云端回补 | 从官网 xlsx 导入过数据后(xlsx 丢约 22%) |
| 导出全量 CSV | 全量导出到 `data/exports/` | 归档 / 交接 |
| 整理数据库 | `wal_checkpoint` + `VACUUM` | 删过数据后回收空间,或 WAL 文件偏大时 |
这些动作**耗时且会占用写权限**,所以有二次确认。执行期间不要重复点击。
### 8.4 修改密码
填「当前密码 / 新密码 / 确认新密码」。改完当前会话仍然有效,其他会话需要重新登录。
---
## 九、日志管理页:出问题先看这里
![日志管理页](images/05-logs.png)
三个区块:
**1. 采集运行历史**(可翻页 + 按状态筛 `ok` / `warn` / `error` / `running`)
每行可展开看**逐行日志原文**——排错时最有用的一块。
**2. 应用日志尾部**
Web 进程自身的日志(启动、异常栈、调度动作)。默认展示尾部若干行。
**3. 操作审计**
谁在什么时候做了什么:登录、登录失败、改配置、触发采集、导出、建号删号……
可按**动作**筛选,支持翻页。
> 排错顺序建议:操作审计(有没有人动过) → 采集历史(采集本身成不成功) → 应用日志(程序有没有异常)。
---
## 十、用户管理页(仅管理员)
![用户管理页](images/06-users.png)
| 操作 | 说明 |
|---|---|
| 新建账号 | 填用户名 / 显示名 / 密码,可勾选管理员 |
| 改显示名 | 行内直接改,保存即生效 |
| 改权限 | 管理员 ↔ 普通用户 |
| 改密码 | 给忘了密码的同事重置 |
| 删除 | 删除账号 |
内置三条护栏(前端和后端都拦):
1. **不能取消自己的管理员身份**(防止把自己锁在门外);
2. **不能删除自己**;
3. **至少要保留一个账号**(防止系统变成没人能登录)。
---
## 十一、常见任务速查
| 我想… | 怎么做 |
|---|---|
| 立刻采集一次 | 任务管理 → 立即采集一次 |
| 回补某几天的数据 | 任务管理 → 按区间补采,填起止日期 |
| 换 Cookie | 配置管理 → 凭证 → 粘贴新 Cookie → 保存 → 回补最近几天 |
| 导出某段时间的数据给别人 | 数据明细 → 选日期 → 导出 CSV |
| 导出全量存档 | 配置管理 → 维护动作 → 导出全量 CSV |
| 找出最贵的请求 | 用量大屏 → 单笔 TOP |
| 看某条请求的完整 Prompt | 数据明细 → 该行「展开」 |
| 给同事开只读账号 | 用户管理 → 新建账号,**不勾**管理员 |
| 同事忘记密码 | 用户管理 → 该行「改密码」 |
| 把数据备份走 | 拷 `data/usage.sqlite`(连同 `-wal`/`-shm`),或导出全量 CSV |
| 关掉自动采集 | 任务管理 → 关「启用调度」 |
| 改采集时刻 | 任务管理 → 每日时刻,如 `08:30,12:30,18:00` → 保存 |
| 系统变慢了 | 配置管理 → 整理数据库;再不行看「十二」 |
---
## 十二、常见问题
### 采集报 `cookie_expired` / `unauthorized`
Cookie 过期。重新按 [3.2](#32-拿-cookie-的两种办法) 拿一份新 Cookie 填进去。
Cookie 有效期通常是浏览器会话级别,**关掉浏览器可能就失效了**——建议用
「办法 B」从已登录的浏览器里复制时,勾选「保持登录」。
### 采集成功但「新增 0 条」
大概率是**正常的**:断点续采意味着没有新请求时确实没有新增。
看「日志管理」里那一次的 `抓取` 条数:
- `抓取 > 0,新增 = 0` → 云端返回的都是库里已存在的,正常;
- `抓取 = 0` → 该时段云端确实没有记录。
### 日期看起来差一天
所有日期都按**部署机器的本地时区**(容器里由 `TZ` 决定,默认 `Asia/Shanghai`)计算。
如果服务器时区不是东八区,跨日的数据会落到相邻日期上。
Docker 部署请确认 `TZ=Asia/Shanghai`;裸机部署确认系统时区。
### 导出的 CSV 在 Excel 里中文乱码
不会——导出已经带 UTF-8 BOM。如果乱码,先确认你打开的是本系统导出的文件,
而不是手工用记事本另存过的版本。
### 提示「采集正在进行中」
同一时刻只允许一个采集(SQLite 单写者)。等当前这次跑完再操作,
在「任务管理 → 运行历史」里能看到它是否还在 `running`。
### 页面能打开但图表空白
1. 强制刷新(`Ctrl+F5`)清掉旧缓存;
2. 检查浏览器控制台有没有资源 404;
3. 到「日志管理 → 应用日志」看有没有异常栈。
### 关掉浏览器后调度还在跑吗
在的。调度在**服务端进程**里,和浏览器无关。要停就去「任务管理」关调度开关,
或停掉服务。
### 忘记管理员密码
在部署机器上执行:
```bash
python manage.py passwd admin 新密码 # 裸机
docker compose exec portal python manage.py passwd admin 新密码 # Docker
```
### 数据会丢吗
正本是宿主机上的 `data/usage.sqlite`。`docker compose down` **不会删数据**;
只有显式 `docker compose down -v` 或手动删目录才会。
定期拷走这个文件(连同 `-wal` / `-shm`)就是完整备份。
### 能不能同时开多个采集进程
不能,也没必要。SQLite 单写者 + 文件锁的设计就是为了避免并发写。
真要跑多副本,除第一份外都要设 `WB_DISABLE_SCHEDULER=1`,
且只有一份能安全写——所以**不要**横向扩展这个服务。
---
更多技术细节见 [架构与设计说明](ARCHITECTURE.md)、[部署与运维指南](DEPLOYMENT.md)、
[接口参考](API.md)。
二进制
查看文件
二进制文件未显示。

之后

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

二进制文件未显示。

之后

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

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

之后

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

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

之后

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

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

之后

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

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

之后

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

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

之后

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

二进制文件未显示。

之后

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

二进制文件未显示。

之后

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