用 Cloudflare 做一本会同步的账本:Open Cookie 的全栈技术设计

用 Cloudflare 做一本会同步的账本:Open Cookie 的全栈技术设计

从 Cloudflare 双 Worker、D1 与 R2,到 PGlite、Expo SQLite 和 Durable Objects:拆开小鹿记的记账模型、Outbox、幂等、增量同步、冲突处理与 AI 草稿链路。

—次点击18分钟阅读

记下一笔午饭钱,界面上只需要几秒钟:输入金额、选个分类,点确认。但如果同一个账本同时出现在浏览器和手机上,问题很快就变了。手机离线时改过备注,电脑又修改了金额;账单导入已经成功,浏览器却没收到响应;AI 识别出一笔消费,用户还没确认就退出了页面。再打开时,系统应该拿出哪一份数据?

Open Cookie,中文名「小鹿记」,就是在这些问题里逐步做起来的。这个版本已经有日常记账、AI 草稿、批量导入、统计预算、旅行分账,以及 Web 和原生客户端。它背后的核心工作,是让各种入口产生的记录最终成为同一本账。

这次我想完整拆开它的技术栈:用 Cloudflare Workers 承接应用,用 D1 保存账本事实,用 R2 管文件,用 Durable Objects 传递实时信号,再用客户端本地数据库和同步协议把多端接起来。 本文以 2026 年 9 月 9 日最后一笔本地提交 51b2afb 为准。

整体架构:两端应用,两个 Worker,一条同步主链路

小鹿记的部署由 Alchemy 描述。Web 是 TanStack Start 应用,API 是 Elysia 应用,分别部署为 Web Worker 和 API Worker。原生端使用 React Native 与 Expo,访问同一套服务端业务接口。

这种拆分让页面渲染与业务服务有各自的入口。Web Worker 处理静态资源和 TanStack Start 请求,API Worker 处理认证、账本、导入、Agent 与同步;环境配置负责把应用地址、API 地址和允许的来源连起来。它不是把三个目录打包到同一个常驻 Node 进程中。

小鹿记的 Cloudflare 架构:Web 与原生客户端共享 API Worker,D1、R2 和实时房间各自承担存储或通知职责。
小鹿记的 Cloudflare 架构:Web 与原生客户端共享 API Worker,D1、R2 和实时房间各自承担存储或通知职责。

左右滑动查看完整表格

组件

在项目里的职责

保存什么

Web Worker

TanStack Start 页面与静态资源

页面构建产物

API Worker

Elysia 路由、认证、业务与同步

请求内的处理状态

D1 + Drizzle

账本、成员、流水、审计、同步日志

已提交的业务数据

R2

票据、图片、语音等附件

文件对象

Durable Object + Yjs

账本实时房间、在线协作与序号通知

实时元数据与房间状态

PGlite / Expo SQLite

两端本地视图、Outbox 与同步游标

当前账号的本地数据

Cron

上传回执检查、报表与周期记账调度

业务服务各自维护任务状态

这里没有因为用了 Cloudflare 就把所有东西都搬进同一种存储。文件和账本需要不同的访问方式,实时通知和交易确认也需要不同的可靠性。分清这些责任,后面才谈得上恢复与重试。

账本模型先稳定,界面才有一致的数字

小鹿记最初要回答的问题很朴素:这个月收入多少、支出多少、钱花在哪、还结余多少。

这四个数字听起来简单,却很容易把产品带向另一套复杂度。要不要维护银行卡余额?信用卡账单和转账算什么?手工漏记一笔之后,所有账户是不是都要重新对账?如果第一版就沿着这些问题扩展,很快会把一个日常记账工具做成半套资产管理系统。

这一阶段的选择是让结余直接从收入和支出派生,支付方式作为流水的一个维度。用户可以按微信、支付宝、银行卡看花销,统计也能按支付方式筛选,而不必先建立完整的账户余额体系。

金额在业务里用整数保存。以人民币为例,28 元对应 amountMinor = 2800;需要汇总多币种流水时,再保留原币金额、币种、汇率和本位币金额。这样,输入时能看见原始消费,统计和预算则有一致的计算口径。

分类、标签和支付方式也各有位置。分类回答“这笔是什么支出”,标签可以保留“美团”“出差”一类线索,支付方式回答“通过什么付的钱”。同一笔外卖可以属于餐饮,带有美团标签,同时使用微信付款,查询时不需要把三个问题挤进一个字段。

一个流水实体需要有稳定 ID,金额、收支方向、币种、日期、分类、支付方式和所属账本。附件通过关联信息指向对象存储,删除则映射为可恢复的丢弃状态。这些规则同时服务手工录入、导入、AI 确认和同步写入,不能由每个入口各算一遍。

共享账本还带来权限层次。认证确认当前是谁,账本服务再判断他能否读、写或管理这个账本。同步 push 会按批次收集需要访问的账本,再检查相应权限;客户端带来的 ledgerId 只是请求目标,不能成为权限证明。

本地优先:先保存修改,再讨论网络

记账经常发生在网络不太配合的时候:付款后的电梯里、旅行途中、刚切回前台的手机上。我不希望一条网络错误就把用户刚输入的内容带走。

当前 Web 端使用 PGlite 作为本地数据层,并通过 IndexedDB 持久化;原生端的实际应用路径使用 expo-sqlite。两端可以有不同的存储实现,但要共享同步协议和业务语义:什么算一次修改、什么时候算服务端接收、遇到冲突怎样处理。

一个需要同步的本地变更,会带着唯一的 mutationId 进入待发送队列。联网后,通过 POST /sync/push 送到服务端;服务端检查账本权限、重复请求和版本冲突,再将业务变化、审计记录、同步日志一起提交。客户端拿到结果以后,通过 POST /sync/pull 按 serverSeq 拉取增量变化,更新本地数据和游标。

同步主流程:本地记录与 Outbox 经 push 提交,服务端生成 serverSeq,客户端 pull 后更新本地视图;实时房间负责提醒拉取。
同步主流程:本地记录与 Outbox 经 push 提交,服务端生成 serverSeq,客户端 pull 后更新本地视图;实时房间负责提醒拉取。

可以用一个简化的请求理解其中的几个字段。下面是协议示意,具体 payload 仍由对应业务校验:

json
{
  "clientId": "device-a",
  "mutations": [{
    "mutationId": "unique-change-id",
    "ledgerId": "my-ledger",
    "collection": "transactions",
    "entityId": "transaction-id",
    "operation": "update",
    "baseServerSeq": 122,
    "payload": { "note": "午餐,已补备注" }
  }]
}

这个协议同时携带修改身份、业务归属和版本基线。重试、权限、冲突分别沿着对应字段判断;暂时断网可以稍后再发,没有权限或基线过期则需要另一种处理方式。下面逐步拆开服务端确认与客户端恢复的过程。

把同步拆成五个可验证的步骤

1. 本地修改要有独立身份

本地业务视图和 Outbox 承担不同的工作:视图让页面立即读到当前记录,Outbox 保存“哪些修改还没有被服务器确认”。原生 SQLite 里能看到 local_entity、local_outbox、local_sync_cursor 和 local_conflict;Web 的存储适配器也提供物化行、待发队列和游标接口。

队列项不仅有 payload,还需要账本、实体、操作类型、基础版本、尝试次数和错误状态。这样,界面才能区分“已经同步”“等待网络”“发生冲突”,而不是只用一个保存成功提示掩盖后续状态。

账号隔离同样属于本地数据库设计。Web 创建 PGlite storage 时使用账号作用域的数据目录;原生端的本地实体、队列和游标都带用户作用域。退出一个账号再登录另一个账号,不能沿用上一位用户的待发修改。

2. 重试必须得到同一个答案

服务端收到重复的 mutationId 时,会检查请求指纹。新格式的指纹覆盖设备、账本、实体、操作和 payload 等内容;一致才返回原先的确认信息和 serverSeq。同一个 ID 被拿去提交另一份修改,会返回冲突。

这条规则约束客户端:第一次发送前就要确定修改 ID,网络重试时继续使用它。如果每一次重试都生成新 ID,服务器看到的就是多次不同操作,幂等保护也无从谈起。

3. D1 的原子提交不能只靠前置查询

同步服务同时写业务表、operation_audit 和 sync_mutation_log。如果业务表已经更新,同步日志却失败,其他客户端就不知道有变化;如果只有日志成功,客户端又可能拉到一条没有对应业务事实的变更。

当前 D1 路径会规划一批语句,再通过 Drizzle 的 batch 提交。Cloudflare 的 D1 文档明确说明,批量语句以事务方式执行,其中一条失败会中止或回滚整个序列。D1 batch 文档

但 batch 并不自动解决“先读取,再决定写入”的竞争。在前置查询结束后,另外一个请求仍可能改变版本或权限。小鹿记因此把关键判断变成批次内部的条件守卫:向 sync_push_guard 写入一个校验结果,这张表要求 valid = 1;条件不成立时触发约束失败,整批业务写入随之回滚。

sql
-- 项目迁移中使用的守卫表结构
CREATE TABLE sync_push_guard (
  id TEXT PRIMARY KEY NOT NULL,
  valid INTEGER NOT NULL,
  CHECK (valid = 1)
);

同一批语句里还推进 sync_server_sequence,将得到的序号写入同步日志,再把序号带回响应。代码里的守卫计划、序号推进和结果索引都围绕这件事组织:返回给客户端的确认,要与实际提交的变化对应。

4. Pull 是补齐数据的通道

客户端通过 afterServerSeq 请求后续变化,服务端返回变更、当前序号和 hasMore。客户端需要分页拉取、应用变化,再推进本地游标;只收到一个“最新序号是 123”的通知,还不等于本地已经有了 123 对应的数据。

删除也要能同步。项目通过 tombstone 表达删除语义,交易业务本身保留软删除状态。另一个离线设备回来后,能知道这条记录已经被丢弃,避免把“查不到”误当成“以前没同步过”。

5. 冲突先保留,再让用户决定

baseServerSeq 告诉服务端这次修改基于什么版本。检测到后续已有同实体变更时,返回 409,而不是直接把新数据覆盖掉。本地修改因此进入需要处理的状态,用户可以接受服务端版本,也可以在取得最新基线后重新提交自己的版本。

这一步的价值不只是数据安全,还包括解释能力。用户需要知道哪笔记录卡住、为什么卡住,以及点哪一个动作会保留哪份内容。自动重试次数再多,也不能替他做这个决定。

Durable Objects 负责提醒,D1 负责确认

实时房间让共享账本更及时,但 WebSocket 断开不应该意味着同步协议失效。当前实现把最新 serverSeq 写入 Yjs 的 metadata,并广播更新与轻量通知;客户端收到信号后,继续走已有的 pull 链路。

服务端在业务提交后尝试发送通知,通知失败不会把已经提交的账本变化改回去。这也意味着,系统恢复不能只依赖一次广播:客户端仍需要在自己的同步流程中按游标补齐数据。

共享账本的通知对象还要按成员范围计算。个人索引房间使用用户作用域,账本房间连接则检查当前权限;只读成员不能因为连上 WebSocket 就获得写权限。数据授权与实时连通是两回事。

Cloudflare 提供 Durable Objects 的 WebSocket 与休眠机制,让房间能够在连接存在时保存协作上下文。具体生命周期和恢复方式应按官方 API 处理,而不能把进程内对象当成永久存储。Durable Objects WebSocket 文档

AI 输入怎样接到这条账本链路上

我希望记账入口足够轻。用户可以打一句话,粘贴付款截图,上传账单文件,也可以用语音描述刚才的消费。输入形式不同,最后应该落到一张看得懂、改得动的草稿上。

小鹿记把解析能力放进 packages/ai-agent,按来源选择相应的 Skills:文字走自然语言解析,照片和截图走图像理解,语音先得到转写结果,文件走账单解析。分类推断、重复提示和确认规则再接到各自的路径里。最新版本会按输入来源组装指令,避免让每一次简单文字记账都携带所有媒体处理规则。

模型负责提出记账草稿,确认动作由应用负责。 这是我在这条链路里最看重的分工。模型猜出了商家、金额或分类,不代表用户已经认可;它引用了上一轮对话,也不意味着可以回头修改那一笔已经确认的交易。

AI 记账主流程:输入经过权限与上下文校验,生成可编辑草稿,由用户确认后入账。
AI 记账主流程:输入经过权限与上下文校验,生成可编辑草稿,由用户确认后入账。

比如输入“刚才那杯咖啡是 32,不是 23”,模型需要理解当前上下文,但不该凭这句话就静默改掉历史账目。服务端提供有限的参考信息,返回新的草稿,再让用户核对。草稿中的日期、分类、商家和支付方式都可以成为人工修正的位置。

表格账单又是另一种情况。CSV 和 Excel 已经有行列结构,金额、时间、交易方向能通过确定性逻辑读取的部分,就应尽量保留原样,再处理字段映射和分类。把整份结构化账单重新交给模型自由复述,反而会增加用户核对成本。

因此,小鹿记没有把“一句话记账”和“批量账单导入”混成一个黑盒。前者解决随手记录的入口问题,后者需要逐行预览、识别重复、映射分类,最后明确提交哪些内容。

上下文和会话恢复,也要服从账本范围

有记忆的记账助手确实更自然。用户上一句提到午饭,下一句只说“用微信付的”,系统需要知道两句话之间的关系。但账本数据又不适合无限制地塞进对话。

最后一个版本把上下文范围写得很具体:读取当前用户的对应会话,最多取最近 8 条消息;有明确账本时,读取这个账本最近 30 天内最多 50 条已确认流水。消息文本、商家和备注还有长度限制。授权和会话归属先在业务服务里检查,之后才加载这些参考内容。

这里的“最近流水”有两个用途。一是辅助分类:同一商家过去通常归到什么分类,可以作为本次判断的线索。二是提示疑似重复:应用会比较金额、币种、收支方向、日期,以及商家或备注,提醒用户再看一眼。

这不是一个覆盖所有历史账目的查重证明。30 天、50 条是明确的参考窗口,匹配规则也只是线索。界面应该说“发现可能重复”,把决定留给用户,而不是自动合并两笔看起来相似的付款。

缓存边界也很重要。代码允许复用按输入来源构建的 Agent 定义和不可变指令;用户会话和财务上下文则按请求加载。复用模型配置是一回事,让不同用户共享上一轮账本上下文是另一回事,后者不能靠约定侥幸避免。

最后一次提交:保住未完成的输入

51b2afb 的提交说明是 preserve conversations and scope skills context。这次调整把重试、临时会话和页面切换后的持久化状态放到一起处理。

一次 Agent 请求开始时,服务端会话 ID 不一定已经存在。前端先用临时会话承接输入,等服务端返回真实 thread,再把当前临时会话关联过去。问题在于,“关联过去”必须有范围:只能迁移属于这个本地临时会话的内容,不能因为一次重试,把别的服务端会话也带走。

另一个问题出现在刷新和切页。请求还在流式返回,用户已经离开页面;旧页面的回调过一会儿才结束,如果它继续写本地存储,就可能覆盖新页面刚创建的待处理请求。最新实现给同一份会话存储增加了当前页面的所有权判断,旧回调可以结束自己的内存状态,但不能抢回新的持久化状态。

未完成的请求会保留必要输入,重新打开时显示为可重试的中断状态。图片、文件和语音还要保留各自的来源及元数据,重试时继续走原来的处理路径。把语音重试降级成一个泛化的“账单文件”,即使技术上还能发出请求,用户看到的进度和最终行为也可能已经变了。

我在这里得到的经验是:流式界面的完成标准,不能只看最后一个文本片段有没有出现。一次输入从发送、断开、恢复到得到草稿,都应该有可追踪的身份。requestId、临时 conversation、服务端 thread 和持久化消息,需要各自知道自己负责哪一段。

批量导入:请求幂等、行去重与失败补偿

6 月底的提交里,账单导入和同步恢复出现得很频繁。功能列表上一行“支持 CSV 导入”,落到实现里却是一串不同的问题。

同一份文件重复上传怎么办?一次导入有几百条记录,中间一批失败了怎么办?服务端已经写入成功,浏览器没收到响应,又点一次确认怎么办?新建的分类写进云端以后,本地列表为什么还显示不出来?

当前导入服务用请求 ID、请求内容指纹和导入批次区分这些情况。对已经完成的同一请求,可以返回原结果;正在处理的请求有自己的领取状态和批次身份;失败恢复也会围绕这一批记录进行清理或补偿。流水自身还有导入指纹,用来识别重复行。

这里有两个不同层次的防重:请求身份避免“同一次确认被执行两遍”,行指纹处理“不同导入请求带来了相同账单行”。只做其中一个,另外一类重复仍然会出现。

大批量导入通过分批写入、完成校验和失败补偿推进,边界比单条新增更复杂。恢复时需要判断哪些行属于当前批次、哪些已经完成、哪些应该撤销,不能直接把一个尚未完成的批次重新当作全新导入。

记录入库之后,分类、标签和流水还要进入同步日志,回到客户端视图。用户能在列表里看到新账、能在统计里得到对应结果,才算一次导入真正结束。

R2 里放文件,D1 里放归属和回执

票据截图、语音和附件体积大,也不适合每次查流水时一并读取。小鹿记让 R2 保存文件对象,D1 保存附件关联、上传意图与上传结果等元数据。通过 Better Upload / S3 适配配置,可以把上传授权与实际文件传输接起来。

上传不是“拿到一个 URL”就结束了。业务还要确认文件属于哪个用户或组织、对应哪个用途、是否已完成、是否关联到了可访问的账本。当前实现区分上传用量的个人和组织归属,并保留待确认回执的检查流程。

文件上传与流水确认也有不同的生命周期。一张截图可以已经上传,但 AI 草稿还没有被确认;不能因此把截图当作一笔正式支出。反过来,流水存在也不保证某个外部文件 URL 永久有效,附件需要有应用自己的对象标识与关联信息。

Cron 与 Worker 初始化:让后台入口可重复运行

9 月的一次整理,是把业务里反复创建数据库客户端的方式收敛到共享 Drizzle 实例。各个业务模块直接从 @open-cookie/db 引入 db,必要的原生 D1 批处理则通过 db.$client 使用。

在 Worker 环境里,这件事带着一个初始化顺序要求:先安装当前环境的 bindings,再动态加载依赖数据库的应用模块。客户端构造本身不执行查询,真正的数据库操作留在请求或定时事件中。把代码改成一行共享导出很容易,确保冷启动时所有入口都遵守同样的顺序,才是这次整理需要检查的部分。

定时任务也做了收口。部署配置使用一个每 15 分钟触发的 Cron,日常处理待确认上传回执;到 UTC 零点,也就是北京时间上午 8 点,再调度报表与周期记账。具体报表是否到期,由报表服务继续判断。

调度使用事件携带的时间,而不只是函数真正开始执行时的当前时间。这样,一次延迟执行仍然可以对应到原来的业务周期。独立任务使用 Promise.allSettled 等待各自结束,再汇总失败,避免一个任务先抛错就遮住其余任务的结果。

这些设计没有让外部调用变成“恰好执行一次”。周期生成、邮件发送或回执处理各自仍然需要自己的状态判断和重试边界。统一入口的价值,是让这些工作在哪里发生、按哪个时间发生,更容易追踪。

这套架构应该怎样验收

我更愿意按故障场景组织验收,而不是只看首页和记账表单能否打开。下面这些检查,对同类项目也有参考价值:

场景

应当观察到的行为

同一修改提交两次

返回原确认,不增加第二条业务记录

同一修改 ID 换了内容

返回冲突,不能静默接受

批次中间的守卫失败

业务表、审计与同步日志一起回滚

手机离线,电脑修改同一条账

手机恢复后保留本地冲突,提供解决入口

退出账号再登录另一个账号

本地队列与数据不跨账号串用

导入成功但响应丢失

使用同一请求身份恢复结果

Agent 中断后刷新

保留可重试输入,旧页面回调不能覆盖新请求

实时连接暂时断开

通过游标 pull 继续补齐数据

最后提交中已经有同步服务、原生离线仓库、Agent 会话和 Worker 冷启动等测试覆盖。它们说明项目把这些问题纳入了回归范围;它们不等同于每一种真实设备、网络和生产配置都已经验收完毕。

用这套栈做记账,真正需要自己实现的是什么

Cloudflare 提供了应用入口、数据库、对象存储、实时房间和定时触发,但账本正确性仍由应用负责:金额口径、账本权限、修改身份、冲突选择、文件归属和失败补偿,都不会因为换了一个部署平台自动成立。

这一阶段的小鹿记给我的答案是先建立可确认的业务记录,再建立可重放的同步协议,最后让 AI、批量导入和旅行分账共享这些规则。Web 和手机可以有不同交互,本地数据库也可以不同,只要对“一笔账什么时候算成立”的理解一致,后面增加入口才不会把旧账打乱。

如果后续继续推进,我会优先补充真实多端断网恢复、长时间离线后的同步效率,以及同步日志增长的处理方案。这些是需要继续验证的工程方向,也比再给首页增加几个统计卡片,更接近这个项目的核心。