部分信息已脱敏。持有访问密钥可查看完整版本。
新能源与建设器材租赁企业
跨部门依赖的社内看板系统
把跑在 Google Sheets 加 Apps Script 上的部门间依赖流程,做成 Cloudflare 边缘上的实时看板;依赖不单独建表,与任务共用同一套模型。17 天单人交付上线。
概要
客户原来的部门间依赖流程跑在 Google Sheets 加 Apps Script 上:谁请谁做、做到哪一步,靠一张共享表格和手工提醒维持。Vulcan 把这套流程做成了看板系统。
跨部门依赖没有单独建表。一条依赖就是一个普通任务,只是它的负责部门不是创建者所在的部门。依赖管理和任务管理因此共用同一张表、同一套查询和同一个看板。
提交依赖的向导先在创建者自己部门建草稿,送信时才改写负责部门并从接收方采番。放弃的草稿只留在自己的看板上,不会污染对方队列,也不需要为此增加一张表。
架构设计
前端是 SvelteKit 的 SPA 模式,整个应用关掉服务端渲染。看板是重交互界面,没有 SEO 需求,SSR 换不到收益,反而让每次交互都要照顾两套渲染路径。
数据只走本应用自己的 BFF 端点,共 42 个。端点跑在 Worker 上直接绑定数据库,浏览器永远拿不到数据库句柄。数据访问收敛在共享的 Drizzle 查询层,应用里不写 SQL。
运行时全部在 Cloudflare 边缘:Worker 承载应用,D1 存数据,R2 放附件,KV 存个人设置,Durable Object 负责状态同步。登录是 Google OAuth,会话与另外两个内部应用共用一套。
技术难点
多人同步:只广播失效信号
一个人改了任务,其他人的看板要跟上,但这里没有引入 CRDT。Durable Object 只当广播站,推送的内容是失效的查询键前缀,客户端收到后让对应缓存过期并重新拉取。数据的唯一真相始终是数据库。
广播分两层收窄。键前缀里带部门和任务标识,前缀匹配天然只命中正在看那块内容的人;个人视图的事件再按员工标识定向投递。连接上的员工标签由服务端网关覆写,客户端伪造不了。
代价换来的是乐观并发和排序逻辑一个字都不用改,Durable Object 里也不存任何业务数据。连接用休眠 API,心跳由运行时自动应答,不唤醒实例。断线时退回轮询兜底。
把 Durable Object 塞进被框架覆写的入口
Cloudflare 适配器每次构建都会重写 Worker 入口文件,手写的 Durable Object 导出放不住。构建脚本因此在编译后把产物移开,原位生成一个包装入口:普通请求转发给框架,连接升级请求拦下,同时导出 Durable Object 类。
包装文件里只有接线,逻辑全部留在有类型检查和测试覆盖的源文件里。脚本自带重入判断,重复执行不会把已移开的产物再覆盖一次。
乐观并发与分数索引
可编辑的行带版本号,更新语句把版本写进条件,影响行数为零就返回冲突。拖拽排序用分数索引,服务端根据落点两侧邻居生成新的排序值,客户端不允许自己写。
两者配合的结果是并发拖拽不会重排整列。抢到同一位置时插入会撞上唯一索引,走有限次重试;采番这类不可回退的操作放在受版本保护的写入之前,输掉竞争最多留一个号码空档。
父任务状态的递归导出
父任务的状态从子任务算出来,不单独存一份。子任务一变就从它的父节点向上逐层重算,某一层算出的状态没变就停止,因为再往上的推导只依赖这个没变的值。另外带一个已访问集合防环。
日语场景下不用全文索引
搜索走部分匹配加应用层打分,没有用 SQLite 的全文索引。默认分词器切不开日语,三字组索引又会漏掉予約、会議这类两字词。检索范围限定在本部门,扫描代价可以接受。
AI 原生的开发方式
这个项目从第一次提交到部署上线是 17 天,400 次提交里有 223 次带着 AI 的共同作者署名。开发方式是把 AI agent 当设计搭档:我定问题和取舍,它出实现,我复核并决定是否留下。
让这种速度可持续的关键是把判断留下来。仓库里四份面向 agent 的文档共两千余行,写的不是代码结构,是每个决定为什么这么定,以及哪些路已经试过并被否掉了。
为什么删掉日历表、为什么依赖不单独建表、为什么搜索不用全文索引,都连同代价写在文档和数据库迁移文件的开头。下一次改到这里的人读到的是判断依据,不用重新猜。
文档同时钉住了几处容易漂移的地方:枚举只在一个文件定义、界面改名不动代码里的查询键、口径不同的日期字段不许合并。每一条都配了对应的测试来守。