一本书是怎么被写出来的 书架

第 08 章 / 共 10 章

第八章 · 收尾与钱

引擎返回之后,startBookJob() 里那个 async 闭包进入收尾:登记对话线、登记书帖、推送、失败则退款告警。这一章同时讲清钱的回路(预扣与退款)和登记簿的存储(为什么真源在 R2 而不是本地)。

这段代码在哪

它做什么

对话线的存储模型。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 全文,留人工对号。注释:「绝不让一本已扣费的书没有主人记录」。

为什么这样写

怎么排查

症状命令 / 位置
书写完了但 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 了但没有 registeredls /opt/claude-agent/bookmeta/_unmatched-*,拿 jobId 人工对号
一本书是怎么被写出来的 · 王建硕
费曼式写法 · 由多个 AI 代理撰写与互相审校