文件
wangchuanli 1bf961f6b3 feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。
新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0,
消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检,
smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收,
忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
2026-09-18 08:46:00 +08:00

20 KiB

接口参考(API)

面向开发 / 集成。所有接口都在 /api 前缀下,返回 JSON。

通用约定

项 说明
认证 全部需要登录。/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)
权限 标「管理员」的接口非管理员访问返回 403
时区 所有日期口径按服务端本地时区(Docker 由 TZ 决定)

错误响应形状

{ "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 已有采集在跑(单写者约束)
no_cookie 409 本账号还没配 Cookie,无法采集
cookie_broken 409 Cookie 密文解不开(cookie_key 换过),需重新粘贴
cookie_expired 401 云端 Cookie 失效,需去「配置管理」更新
api 502 云端接口异常
internal 500 服务端异常

读接口

GET /api/manifest

存档总览。无损、代价小,适合做探活与元数据展示。

{
  "schema": 4,
  "generated": "2026-09-14 14:52:20",
  "archive": "workbuddy-portal",
  "producer": "workbuddy-portal(Flask + SQLite)",
  "note": "...",
  "totals": {
    "records": 944, "credits": 4961.63, "calls": 944,
    "freeCalls": 328, "billableCalls": 616,
    "models": 7, "clients": 3,
    "first": "2026-08-16 09:12:00", "last": "2026-09-14 15:52:00",
    "topCredits": 412.8
  },
  "months": ["2026-08", "2026-09"],
  "sources": [{ "path": "usage.sqlite", "role": "primary", "count": 944, "bytes": 434176 }],
  "focusDay": "2026-09-14",
  "health": { "cookie": true, "lastRunAt": "2026-09-14 15:52:10", "lastRunStatus": "ok" }
}

GET /api/bundle

大屏页专用:一次取齐所有需要的数据,避免切页时多次往返。

参数 必填 默认 说明
from / to 否 全量 筛选窗口
topN 否 30 「单笔 TOP」返回条数(1~1000)
{
  "manifest": { ... 同上 ... },
  "daily": [
    { "d": "2026-09-08", "c": 168.4, "k": 31, "fc": 12, "bc": 19,
      "m": { "demo-flash": 62.1, "demo-pro": 41.7 },
      "h": [0,0,0,0,0,0,0,0,0,12.5, ...] }
  ],
  "dims": { "model": [...], "client": [...], "hour": [...] },
  "top":  [ { "id": "...", "c": 412.8, "m": "demo-reason", "cl": "vscode", "t": "2026-09-12 15:04:00", "px": "摘要…" } ],
  "records": [ { "id": "...", "c": 5.78, "m": "...", "cl": "...", "t": "...", "px": "..." } ],
  "recordsTotal": 944,
  "recordsCap": 20000,
  "recordsTruncated": false,
  "totals": { ... },
  "window": { "from": "...", "to": "...", "days": 7 }
}

关键语义(改动前务必先读 架构说明):

字段 范围 说明
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 缺省时自动取全量区间。

{
  "records": 226, "credits": 1180.4, "calls": 226,
  "freeCalls": 78, "billableCalls": 148,
  "firstDay": "2026-09-08", "lastDay": "2026-09-14", "days": 7,
  "models": 7, "clients": 3,
  "first": "...", "last": "...",
  "window": { "from": "2026-09-08", "to": "2026-09-14", "days": 7 },
  "avgPerCall": 5.22,
  "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 筛选窗口
{ "days": [ { "d": "2026-09-08", "c": 168.4, "k": 31, "fc": 12, "bc": 19,
              "m": {...}, "h": [24 个元素] } ] }

h 是 24 个时段的积分数组,索引即小时。

GET /api/dims

参数 默认 说明
dim 全部 model / client / hour,只返回该维度
{
  "model":  [ { "name": "demo-flash", "credits": 1286.4, "calls": 252, "avg": 5.1, "free": 0 } ],
  "client": [ { "name": "vscode", ... } ],
  "hour":   [ { "name": "14", ... } ]
}

GET /api/top

单笔消耗榜。唯一会返回 Prompt 摘要的列表接口。

参数 默认 说明
from / to 全量 筛选窗口
n 50 返回条数(1~1000)
[ { "id": "…", "c": 412.8, "m": "demo-reason", "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% 体积)
{
  "items": [ { "request_id": "…", "credits": 5.78, "model": "…",
               "client": "…", "ts": "2026-09-14 14:20:00", "prompt": "…" } ],
  "total": 944, "page": 1, "size": 50, "pages": 19,
  "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

{
  "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,
  "is_admin": false,
  "can_edit_schedule": false,
  "can_view_logs": false
}

最后三个字段是前端显隐的依据(v1.3.0 起)。 调度时刻是实例级的 —— 普通账号拿到 can_edit_schedule: false, 页面据此把「采集调度」渲染成只读表格,而不是给一个点了会被拒的表单。 大屏是拿不到 Jinja 上下文的静态页,只能靠 /api/manifest 的 role 或这里的字段决定要不要显示「日志管理」入口。

GET /api/audit

操作审计分页。

参数 默认 说明
action — 按动作精确筛选(值取自返回的 actions)
page / size 1 / 50 分页(size 上限 500)
{
  "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 这个键固定为空串,真正的状态在 cookie_hint / cookie_broken; 内部簿记键(slot:*)根本不返回。 下例中的数字与尾号都是合成示例数据,不是任何真实实例的值。

{
  "api_base": "https://www.workbuddy.cn",
  "api_path": "/billing/meter/get-user-request-usage",
  "page_size": "200",
  "schedule_times": "09:00,17:00",
  "ssl_verify": "1",
  "cookie": "",
  "cookie_hint": "92 字符,…c0ffee",
  "cookie_broken": false,
  "user_agent": "Mozilla/5.0 (...)",
  "_globalKeys": ["allow_register", "api_base", "api_path", "captcha_length",
                  "captcha_policy", "catch_up", "catch_up_grace_hours",
                  "drift_tolerance_minutes", "max_prompt", "page_size",
                  "register_max_per_ip", "rewind_minutes", "schedule_enabled",
                  "schedule_times", "ssl_verify", "timeout", "verify_days"],
  "_userKeys": ["cookie", "user_agent"],
  "_canEditGlobal": true,
  "_role": "user"
}
字段 说明
各配置键 有效值(个人 → 实例 → DEFAULTS 三级回落后的结果)
cookie 恒为空串 —— db.get_settings() 统一置空,明文只能经 db.get_secret() 取
cookie_hint 「N 字符,…尾 4 位」;未配置时为空串
cookie_broken true 表示密文解不开(cookie_key 换过),需重新粘贴 Cookie
_globalKeys 实例级键清单(所有账号共用一份,只有管理员能改)。v1.3.0 起含调度与采集参数
_userKeys 个人级键清单,即 config.USER_EDITABLE_KEYS;普通账号唯一能写的两个键
_canEditGlobal 当前账号能否改 _globalKeys 里的键
_role "admin" / "user" —— 前端据此决定显隐(大屏走 /api/manifest 的 role)

写权限就一条规则:config.writable_by(key, is_admin)。 页面上「哪些输入框可以改」与接口「哪些键能写」用的是同一个函数, 所以不会出现「界面置灰但接口还能写」的不一致。

GET /api/users(管理员)

{ "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() 只挑安全字段)。

POST /api/profile

改自己的显示名与邮箱。

{ "display_name": "新显示名", "email": "me@example.com" }

POST /api/captcha

验证码机制的自述,便于排障时自检(不需要猜当前策略是什么)。

{ "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)。

参数 默认 说明
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

手动触发采集(同步执行,页面等待结果)。

请求体(都可选):

{ "from": "2026-09-01", "to": "2026-09-07" }
场景 响应
成功 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 重新统计并返回当前条数 本人

未知动作返回 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 条/页 之间"]}
不可写的键(普通账号写实例级) 400 {"ok": false, "error": "invalid", "errors": ["以下配置仅管理员可修改,本账号无法保存:api_base。普通账号可以维护的是本人凭证(Cookie / User-Agent)。"]}

要点:

  • 写权限判断只有一处:config.writable_by(key, is_admin)。 普通账号能写的只有 cookie 与 user_agent(且只限本人这份); 其余(云端接口、注册策略、调度、采集参数)全部仅管理员。
  • 越权写是「整单拒绝」而不是「部分生效」:请求里只要含一个不可写的键, 整个请求 400,并在 errors 里点名是哪些键。这样调用方不会误以为 「既然 changed 里没有它就说明写过了」。
  • cookie 留空 = 不修改(不会把已有 Cookie 清掉);写 __clear__ 或 - 才是清空;
  • cookie 落库前会自动加密(db.set_secret),写进去的永远不是明文;
  • 未知键被忽略并在 ignored 里列出,不会被写成任意键;
  • 内部键(slot:*)被忽略 —— 它们是调度簿记,不属于用户可配置项;
  • 改 schedule_times 会清掉已不存在时刻对应的 slot:* 标记(所有账号一起清), 新时刻立即生效。刻意不做全清:全清会让所有账号在宽限期内一起重采。
  • 每次拒绝都会写一条 settings_rejected 审计(管理员的「日志管理 → 操作审计」里能看到, 这也是排查「谁的账号在试越权」的入口)。

POST /api/password

修改自己的密码。

{ "old": "旧密码", "new": "新密码", "new2": "新密码" }

校验:非空、两次一致、长度上限 128。旧密码错误返回 400。

POST /api/users(管理员)

{ "username": "viewer", "display_name": "只读同事", "email": "viewer@example.com",
  "password": "…", "password2": "…", "is_admin": false }

is_admin 默认 false(多用户系统里「默认给管理员」是最常见的越权起点)。 用户名 / 口令强度与自助注册同一套校验。

POST /api/users/<id>(管理员)

{ "display_name": "新名字", "email": "…", "is_admin": true,
  "status": "active", "password": "可选,重置密码" }

自锁护栏(服务端强制,全部返回 400):

  1. 不能取消自己的管理员身份;
  2. 不能停用自己的账号;
  3. 不能把最后一个启用状态的管理员降权或停用。

POST /api/users/<id>/delete(管理员)

删除账号。护栏:

  1. 不能删除自己;
  2. 至少要保留一个账号;
  3. 不能删掉最后一个启用状态的管理员。

请求体可选 {"keep_data": true} 保留其用量数据;默认连同数据与 Cookie 一起删除 —— 留下孤儿数据既占空间,也会在有人重新注册同名账号时被看到(user_id 复用风险)。

违反返回 400。


集成示例

curl(读接口)

# 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)

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。