引擎返回之后,startBookJob() 里那个 async 闭包进入收尾:登记对话线、登记书帖、推送、失败则退款告警。这一章同时讲清钱的回路(预扣与退款)和登记簿的存储(为什么真源在 R2 而不是本地)。
这段代码在哪
src/server.ts:readBookMeta()/writeBookMeta()/patchThreadEntry()(对话线存储);registerBookPost()/notifyBookDone()/notifyAdmin()(三个外向调用);refundBook()(退款);startBookJob()收尾段(_unmatched落档)。
它做什么
对话线的存储模型。bookmeta 是一本书的「对话线」——创建与历次修改的流水账({slug, scope, author, createdAt, thread:[…]})。真源是 R2 的 books/<slug>/_src/bookmeta.json,本地 bookmeta/ 目录只剩两个用途:老条目的读回退(读到即懒迁移上 R2)和 _unmatched 落档。写路径:
function writeBookMeta(meta: BookMeta): Promise<void> {
const p = metaWriteChain.then(async () => {
const body = JSON.stringify(meta, null, 2) + "\n";
try {
const tok = await publisherToken();
if (!tok) throw new Error("no publisher token");
const r = await fetch(`${FILES_API}/upload/books/${meta.slug}/_src/bookmeta.json`, {
method: "PUT",
headers: { Authorization: `Bearer ${tok}`, "Content-Type": "application/json" },
body,
});
if (!r.ok) throw new Error(`upload ${r.status}`);
await unlink(metaPath(meta.slug)).catch(() => {}); // R2 落定后清掉本地旧件
} catch (e) {
console.error(`[bookmeta] R2 upload failed slug=${meta.slug}:`, …);
await mkdir(BOOKMETA_DIR, { recursive: true });
await writeFile(metaPath(meta.slug), body);
}
});
metaWriteChain = p.catch(() => {});
return p as Promise<void>;
}
三个要点:metaWriteChain 把写串行化(防 create 收尾与 revise 并发时「读-改-写」互相覆盖);上传失败回落本地文件(宁可暂留 VPS 也绝不丢条目);R2 落定后删本地旧件(防回退读到陈旧线)。读路径反过来:先 fetch R2 公开 URL(带 ?_=Date.now() 穿透书页路由的 5 分钟边缘缓存——App 轮询 history 要看到实时进度),失败才读本地。
收尾的三个外向调用,全部尽力而为、失败只留日志:
| 调用 | 认人方式 | 干什么 |
|---|---|---|
registerBookPost(auth, slug) | 用户 bearer | 把书 upsert 成社区一等帖(share_id "book-<slug>"),赞/回应/推荐与普通帖同权 |
notifyBookDone(auth, slug, title) | 用户 bearer | 给主人发 APNs「书写好了」;只有 ok 才推 |
notifyAdmin(title, body) | 发布账号 token(worker 校验 scope==ADMIN_SCOPE) | 失败/放弃立刻推管理员 |
顺序有讲究:先 registerBookPost 再 notifyBookDone——「推送里的书立刻可在社区看到」。失败不推用户:「钱的事人工处理,别用推送吓人」。
退款。预扣一口价的书没写成,用下单时那枚 bearer 调 worker 的 book-refund:
async function refundBook(
auth: string | undefined,
extra: { ref: string; kind?: "revise" },
): Promise<void> { …
console.log(`[book] refund ref=${extra.ref} kind=${extra.kind ?? "book"} status=${r.status} ${body?.deduped ? "deduped" : `refunded=${body?.refunded_suanli ?? "?"}`}`);
幂等靠 ref:写书 ref=jobId、修书 ref=slug#ts,worker 端同 ref 只退一次(返回 deduped)。startBookJob() 里有两处退款调用——正常失败分支(if (!ok))和 catch 分支——但它们互斥:try 走完就不进 catch,注释原话「双保险不会双退」。第二处的存在理由是引擎抛异常(超时/崩溃)时 ok 分支根本走不到。
找不到 slug 的兜底:引擎返回了(甚至 ok)但 findSlugByJobId 还是找不到——全书没一个地方承认这个 jobId。这时落 bookmeta/_unmatched-<jobId>.json,内含 jobId/scope/author/instruction 全文,留人工对号。注释:「绝不让一本已扣费的书没有主人记录」。
为什么这样写
- 真源放 R2 是因为 VPS 会重装:注释记着 2026-08-23 的迁移——本地文件会随 VPS 重装丢失,R2 天然持久,而且公开 URL 读、files API 写,不需要新基础设施。
- 写走发布账号、读走公开 URL:上传用
publisherToken()(与 build.mjs 同源的~/.config/voicedrop/credentials)——所有书由同一个发布账号发布,存储位置分不出下单人,这正是 bookmeta 必须存在的原因(下单人只在扣费那一刻可知)。 - 收尾全幂等:patchThreadEntry 按 ts 对号、退款按 ref 幂等、书帖是 upsert。收尾中途进程死掉重来一遍,每一步都安全。
怎么排查
| 症状 | 命令 / 位置 |
|---|---|
| 书写完了但 App 没有对话线 | 直接探真源:curl -s "https://jianshuo.dev/voicedrop/books/<slug>/_src/bookmeta.json";没有再看本地 /opt/claude-agent/bookmeta/<slug>.json |
| 怀疑上传失败 | journalctl -u claude-agent | grep '\[bookmeta\]'(R2 upload failed) |
| 用户说没收到推送 | grep '\[book\] push' 看 status;4xx 多半是 bearer 过期 |
| 要对一笔退款 | grep '\[book\] refund'——refunded=160 是退了,deduped 是重复请求被幂等拦下 |
| done 了但没有 registered | ls /opt/claude-agent/bookmeta/_unmatched-*,拿 jobId 人工对号 |