## 项目定名 - 目录 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 条)
413 行
13 KiB
Markdown
413 行
13 KiB
Markdown
# 接口参考(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。
|