数据隔离
- 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 同步
18 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
}
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", "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(管理员)
{ "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留空 = 不修改(不会把已有 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
修改自己的密码。
{ "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):
- 不能取消自己的管理员身份;
- 不能停用自己的账号;
- 不能把最后一个启用状态的管理员降权或停用。
POST /api/users/<id>/delete(管理员)
删除账号。护栏:
- 不能删除自己;
- 至少要保留一个账号;
- 不能删掉最后一个启用状态的管理员。
请求体可选 {"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。