这一章拆开链条的第一步:HTTP 入口 handleBook() 和它唯一的门禁 chargeBook()。核心设计一句话:没有独立的权限系统,扣费就是准入。
这段代码在哪
src/server.ts的handleBook():路由POST /api/book的处理函数。src/server.ts的chargeBook():把用户 bearer 转发给 agent worker 的BOOK_CHARGE_URL(默认https://jianshuo.dev/agent/usage/book-charge)。src/server.ts的fetchAuthorName():署名解析。
它做什么
handleBook() 的分支逻辑,按执行顺序:
| 条件 | 结果 | 说明 |
|---|---|---|
| seed 为空 | 400 empty seed | seed 先 trim().slice(0, 20000),防巨型 body |
bearer 不是 Bearer … 开头 | 401 bad token | 在 chargeBook() 里判 |
charge 返回非 200 或 !body.ok | 原样透传状态码(如 402) | 402 的 body 带 need_suanli/suanli,App 据此弹充值 |
payload.dry === true 且 charge 通过 | 200 返回 charge 结果 | 只验余额不扣费,开跑前的预检 |
| 全部通过 | 202 {ok, charged_suanli, suanli} | 同步起 runBookJob(),不等待 |
扣费调用本身是一次转发——lab 不自己算钱,钱的事全在 agent worker:
async function chargeBook(
auth: string | undefined,
extra: { seed?: string; slug?: string; kind?: "revise" },
dry: boolean,
): Promise<{ status: number; body: any }> {
if (!auth?.startsWith("Bearer ")) return { status: 401, body: { error: "bad token" } };
try {
const r = await fetch(BOOK_CHARGE_URL, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify({ ...extra, seed: extra.seed?.slice(0, 200), dry }),
});
const body = await r.json().catch(() => ({}));
return { status: r.status, body };
} catch {
return { status: 502, body: { error: "charge unreachable" } };
}
}
注意三个细节:
- seed 只转前 200 字:扣费服务只需要知道这是什么单,不需要全文——全文已经落到 lab 本地,没必要在网络上传两遍。
- worker 不可达返回 502,而不是放行。宁可暂时拒客,不免费开写。
- 定价不在本文件:一口价 160 算力写在 agent worker 的
usage.js的BOOK_SUANLI里,注释原话是「一口价扣 160 算力(真源在 agent/src/usage.js 的 BOOK_SUANLI)」。改价格不用动 lab。
署名在扣费成功后才解析:App 显式传的 author 优先;没传就用同一枚 bearer 去拉提交者的 CLAUDE.json 取 profile.name:
const author =
String(payload?.author ?? "").trim().slice(0, 20) ||
(await fetchAuthorName(req.headers.authorization));
runBookJob(seed, String(charge.body.scope ?? ""), author, req.headers.authorization);
fetchAuthorName() 拉不到就返回空串——空的意思是「这本书不署名」。注释里有一条硬规矩:「绝不回落到任何默认人名」。
为什么这样写
- 认证外包给 worker:lab 不存用户表、不内置任何密钥(注释:「app 里零内置密钥」)。用户身份的唯一证明就是他手里那枚 VoiceDrop bearer(
anon_*或 session JWT),lab 拿它去问 worker「这是谁、余额够不够」。bearer 既认人又扣钱,一次调用办完两件事。 - Caddy 对 /api/book 豁免 basic_auth:lab 的网页聊天靠 Caddy 一层全局密码挡住外人,但 App 的写书请求走自己的 bearer,不该再吃那道密码。所以 Caddyfile 单独豁免这个路径,认证责任整体下移给
chargeBook()。 - 没有并发闸门:写书路径没有自己的并发上限——
MAX_CONCURRENT_RUNS(默认 2)只拦/api/chat。写书的闸门就是算力:每单先扣 160,账户没钱自然进不来。这是 2026-08-10 用「扣费即准入」替换掉「须有成文文章 + 每日限额」旧门槛后的结果。
怎么排查
| 症状 | 先看 |
|---|---|
| App 报「算力不足」 | 正常流程——402 的 body 里有 need_suanli,让用户充值即可 |
点了写书没反应、无 [book] start 日志 | 手动复现:curl -X POST localhost:8787/api/book -H "Authorization: Bearer <token>" -d '{"seed":"测试","dry":true}',看返回哪一档 |
| 全部 401 | bearer 过期,让用户在 App 里重新登录 |
全部 502 charge unreachable | lab 到 jianshuo.dev 的出站网络断了,查 VPS 出口 |
| dry 通、真扣失败 | worker 端 dry 与真扣路径分叉,去查 agent worker 日志 |