POST · 后端 · 文章
我借助 AI 做了一个协作文档项目:从块编辑器到 CRDT,我一路学到了什么
2026/11/11 · by ZHANG XING YUN · 阅读 7 次 · 14278 字 · 预计阅读 36 分钟
写在前面
我做 Nexus,最初是想练习块编辑器和实时协同,后来它逐渐变成一个面向小团队的自托管协作文档工作台:从块编辑、多人协同、工作区权限,一路长到匿名分享、Markdown 导入导出、后台任务、可观测性和 AI 工作台。回头看,功能清单不是最值得分享的部分;真正让我成长的,是每加一层能力,都得重新回答“数据谁说了算、失败时怎么办、权限在哪里拦”。
这篇文章我想写成开发者复盘,而不是项目说明书:我如何借助 AI 学习陌生技术、怎么把一个大目标拆成可验证的小问题、哪些地方我没有照单全收、哪些设计在实现时被现实改写,以及做到今天我还欠下哪些功能。
我确实借助 AI 做了不少事,但我不想把它写成“输入一句话,AI 搭出完整产品”的故事。对我来说,AI 更像一个随时能追问的技术搭档:它可以解释 CRDT、列方案、补测试、找边界条件、帮我梳理设计稿;但它给出的答案仍然要我回到代码、测试和运行配置里核对。特别是这个仓库里,设计稿和最终代码有差距,写在计划里不等于已经交付。
文中“当前实现”以仓库代码和文档核对,基线为 3b8b44e(2026-08-05)。这不是生产运行报告;部署配置、AI Provider 可用性和未重新执行的回归测试仍会影响实际可用状态。
如果你也在用 AI 学一项新技术,我这次最想分享的不是某个框架,而是这条路线:
- 先把问题做小:从单机块编辑器开始,不一上来就做“完整 Notion”。
- 先学模型,再接库:我先搞清楚 Block、富文本 Schema、CRDT、revision 分别解决什么问题,再让 AI 帮我比较实现路径。
- 每一步都能验证:设计接口、数据结构和失败行为,再写代码与测试;不把 AI 生成的代码当作已验证的实现。
- 主动记录没做的事:未实现、默认关闭、真实数据库未验证,都应该和已交付能力放在同一篇文章里。
最终,Nexus 用 Next.js 做 Web,Yjs 做实时合并,PostgreSQL 做业务权威数据,Redis 做限流和协同传播,并将长耗时工作交给独立 worker。下面我会按自己学习和迭代的顺序讲,而不是按目录逐项介绍功能。
1. 我先学会的不是技术,而是缩小问题
项目早期我很容易被“做一个 Notion”这种大目标带偏。AI 也很擅长顺着这个目标列出几十项功能:页面树、数据库、评论、权限、离线、搜索、AI……但列表长,不代表路线清楚。我后来把问题改小:先做一个真正能编辑、能保存的块编辑器,再逐步引入协同、服务端持久化、成员与权限。
这一步给我的第一个提醒是:让 AI 帮你扩展范围很容易,让它帮你守住范围更重要。 我把用户、主要场景、阶段目标和暂缓功能写进 PRD,之后每次讨论新能力,都先问它属于哪个阶段、依赖哪些已存在的能力,而不是看到一个点子就开始加代码。
我给自己定下的场景是:
- 团队知识库、项目文档、会议纪要。
- 多人同时编辑同一篇文档,能看到彼此的光标和在线状态。
- 文档有私有、团队、链接只读三种可见范围。
- 内容可以用 Markdown 或带附件的 ZIP 迁入迁出。
- 数据不托管在第三方文档平台。
我也把非目标列出来:不做企业 SSO/SAML/SCIM、不做计费和插件市场、不做原生移动端、不做能自主执行不可逆操作的 Agent。这个清单不是说这些东西不重要,而是提醒我:个人项目最稀缺的资源是注意力,范围失控会让每一条核心链路都只做一半。
这个边界后来直接影响技术选型:单机 Compose,而不是先上 Kubernetes;PostgreSQL 事务队列,而不是为了“架构完整”先引入 Kafka;先做模块化单体,而不是把每个领域拆成微服务。AI 也先限制在写作、任务提取和有依据的问答,不允许它替我做权限变更或删除文档。
我借助 AI 学习时,实际采用的循环
我现在更愿意把 AI 放进一个有反馈的学习循环,而不是把它当成代码自动售货机:
- 先说清楚我不懂什么。 例如“Yjs 的 update 如何持久化”比“帮我做实时协同”更容易得到有用回答。
- 让 AI 给多个方案和代价。 不只问怎么做,也问失败模式、数据一致性和部署成本。
- 把答案落到本项目边界。 方案是否适合现有 PostgreSQL、是否要迁移、权限是否能复用,都要结合仓库判断。
- 我自己读关键代码。 重点看数据入口、事务边界、错误处理和生命周期,不只看生成出来的新文件。
- 用测试或可重复步骤证伪。 测试没跑、真实 PostgreSQL 没连上,就不能把“看起来合理”写成“已经通过”。
- 把结论和未完成项写下来。 后续的我和读者都不必重新猜测当时的假设。
AI 在这里加快了我查资料、比较方案、梳理边界的速度,但做取舍、判断证据够不够、承担上线风险的人仍然是我。
2. 第二步:每个技术选型都要回答“它解决什么问题”
这是我最终留下来的技术栈。重要的不是“用了什么热门技术”,而是我能不能说明它在这条链路里负责什么:
| 层次 | 选择 | 用途 |
|---|---|---|
| Web | Next.js 15、React 18、TypeScript 5.7 | 页面、Route Handler、SSR、服务端逻辑 |
| 编辑器 | TipTap 2.27、ProseMirror | 结构化富文本、Schema、协同扩展 |
| 实时协同 | Yjs 13、y-websocket、y-protocols、y-prosemirror | CRDT、WebSocket 房间、Awareness |
| 数据 | PostgreSQL 16 | 业务数据、版本、协同增量、任务队列、AI 运行记录 |
| 缓存与传播 | Redis 7 | 会话缓存、限流、多实例协同 Pub/Sub |
| 文件 | 本地 Docker 卷或 S3 兼容存储、AWS SDK | 图片、附件、导出 ZIP |
| 认证 | Argon2id、JOSE、HttpOnly Cookie、Arctic | 密码哈希、JWE 凭据、GitHub OAuth |
| UI | Radix UI、Tailwind CSS 4、Lucide | 对话框、菜单、编辑器外壳 |
| Markdown | Unified、remark、rehype、KaTeX、Mermaid | 导入导出、公式与图表预览 |
| 测试 | Vitest、Testing Library、Playwright、pg-mem | 单元、组件、E2E、数据库替身 |
| 交付 | Docker、Docker Compose、Node.js 20、pnpm 10 | 单机自托管 |
2.1 我为什么先用 Next.js,而不是先拆前后端
认证、工作区、权限、文档差量保存、文件代理、分享链接、AI SSE 和后台任务投递都需要服务端参与。对我这个项目规模,Next.js Route Handler 让我先在一个代码库里把页面、API 和领域逻辑跑通,不必同时维护两套部署和接口类型。
我也付出了代价:
- Server / Client 边界需要严格管理。带
NEXT_PUBLIC_的变量会进浏览器 bundle,所以 AI Key、Provider URL、内部 Token 一律不能碰这个前缀。 - SSE 长连接在 Next.js 里要自己处理取消、背压和代理缓冲,不能把它当成普通 JSON 接口。
- 单体意味着 Web 进程同时承担页面渲染和 API,重活需要谨慎,所以文档索引、邮件投递、配额清理被拆到了 worker。
2.2 我怎么从 TipTap 学到“编辑器不是 contenteditable”
我一开始关注的是“输入框怎么显示”,后来才意识到编辑器的难点是文档模型、光标映射、撤销栈、粘贴、格式化和协同如何保持一致。块编辑器需要可约束的 Schema、可序列化的 JSON 和成熟的扩展生态,所以我选 TipTap/ProseMirror,而不是自己从 contenteditable 开始造轮子。TipTap 与 y-prosemirror 也能把富文本绑定到 Yjs 的 XmlFragment。
我没有把整页都塞进 TipTap。段落、标题、引用、待办这四类块使用结构化富文本;表格、看板、代码、附件、公式、Mermaid 等仍是各自的块数据。这个边界让我学到:抽象不是越统一越好,差异很大的数据硬塞进一种模型,只会把复杂度藏起来。
2.3 我为什么用 Yjs,以及它没有替我解决什么
我想借这个项目理解 CRDT,但不是重写 CRDT。并发编辑、离线更新、状态向量和冲突收敛都不是适合从零试错的基础设施。Yjs 生态成熟,y-prosemirror 处理富文本,y-protocols 提供 Awareness 与同步协议,y-websocket 提供房间基础。
我后来学到,Yjs 只回答“并发更新怎么合并”,不回答权限、关系型查询、历史版本和跨文档检索。因此我把它当协同层,而不是把它误当整个应用的数据库。
2.4 我为什么用 PostgreSQL 做任务队列
这是我后来才真正理解的选型:邀请创建要求“邀请记录 + 审计事件 + 投递任务”一起提交。Redis 队列并不差,但 PostgreSQL 和 Redis 之间没有原子事务;如果我选 Redis,就还要做 outbox 和转发器,增加当时并不需要的复杂度。
我最后选择 PostgreSQL 事务队列:
- 入队和业务写入同事务,不会出现“邀请创建了但任务没入队”。
- worker 用
FOR UPDATE SKIP LOCKED领取任务,多实例天然分摊。 - 代价是吞吐和调度能力不如专用队列,但当前规模根本用不上。
这不是“PostgreSQL 比 Redis 好”,而是我学会了先找当前最重要的约束:在这个规模下,事务边界比吞吐更重要。
2.5 我没有把设计稿当成已完成的 AI 检索
AI 设计稿里写过 PostgreSQL + pgvector、全文和向量混合召回。实现落地时,我当前做出来的是更轻的方案:embedding 以数组存进 ai_document_embeddings,问答时取工作区候选,在 Node 进程里算余弦相似度。
我不把它包装成更优方案,它只是阶段性折中:默认 PostgreSQL 镜像没有 vector 扩展,我也没有为当前规模另加向量数据库。代价很直接:候选集全量扫描,没有 ANN 索引,也没有索引版本和模型切换机制。重要的是把这个差距承认下来,而不是让设计文档替代码“提前交付”。
3. 第三步:从“能跑”走向“出错时知道谁负责”
Browser
├─ HTTPS ──> Next.js Web ──┬─ PostgreSQL
│ REST/SSE ├─ Redis
│ └─ S3 / local volume
│
└─ WSS ──> Collaboration ─┬─ PostgreSQL
(Yjs) └─ Redis Pub/Sub
PostgreSQL background_jobs <── Worker ──> SMTP / Embedding Provider
Prometheus <── metrics ── Web / Worker / Collaboration
| 服务 | 职责 | 不负责 |
|---|---|---|
| Web | 认证、工作区、文档 API、权限判定、附件访问、分享页、AI SSE、任务投递 | 长连接协同、批量索引、邮件重试 |
| Collaboration | Yjs WebSocket、Awareness、房间生命周期、协同落库、内部 flush/apply | 页面渲染、业务 REST、用户管理 |
| Worker | 邀请邮件、AI 文档索引、AI 配额过期清理、任务清理 | 浏览器长连接、同步请求 |
| Migrate | 幂等数据库迁移 | 常驻运行 |
| Prometheus / Grafana | 指标抓取与面板 | 业务逻辑 |
我把 Web、协同服务和 worker 分开,不是为了画出更漂亮的架构图,而是它们的运行方式不同:Web 处理短请求和 SSE,协同服务维护 WebSocket 长连接,worker 执行可重试任务。拆开后,协同服务挂掉时 REST/OCC 仍可作为降级路径;worker 挂掉时任务会积压但不会随请求一起丢;Web 与协同的故障也更容易分别定位。
我也没有把它说成高可用架构:Compose 默认单 Web、单协同、单 worker,PostgreSQL 和 Redis 都是单实例。这适合单机自托管和学习完整系统链路,不等于适合多租户 SaaS。
4. 第四步:把内容模型想清楚,再讨论怎么存
我最终把一个块建模成这样:
interface Block {
id: string;
type: BlockType;
headingLevel: HeadingLevel;
content: string; // 纯文本投影
richText: RichTextDocument | null;
data: BlockData | null; // 表格、看板、附件、公式等
checked: boolean;
comments: BlockComment[];
assignee: string;
dueDate: string;
status: BlockStatus;
parentId: string | null;
children: string[];
createdAt: number;
updatedAt: number;
}
现在有 16 种块:段落、标题、待办、引用、代码、图片、文件、表格、看板、分隔线、无序列表、有序列表、折叠块、公式、链接卡片、Markdown。这个数量不是我一开始就设计出来的;它是在块模型稳定后逐步扩展的。
其中只有 paragraph、heading、quote、todo 使用结构化富文本。富文本 Schema 也不大:
const richTextSchema = new Schema({
nodes: {
doc: { content: "block+" },
paragraph: { content: "inline*", group: "block" },
text: { group: "inline" },
hardBreak: { atom: true, group: "inline", inline: true },
mention: {
atom: true,
attrs: { kind: { default: "person" }, label: { default: "" }, targetId: { default: "" } },
group: "inline",
inline: true,
},
},
marks: {
bold: {}, code: {}, italic: {},
link: { attrs: { href: {} }, excludes: "", inclusive: false },
strike: {},
},
});
我为什么让一块同时保留 content 和 richText?
richText负责表达格式、链接、提及和块内换行。content是纯文本投影,负责搜索、历史摘要、Markdown 导出、AI 分块和旧数据兼容。- 读取旧数据时,如果没有合法
richText,就用content惰性生成,不执行全表回填。
代价是两份表示要保持一致。我用共享的 normalizeRichText 和服务端校验约束写入;匿名分享保留格式,但去掉 mention 的内部目标。这里让我学到一个很实际的事:为了兼容、搜索或展示冗余保存数据可以有价值,但必须同时设计“谁是权威表示”和“冗余表示怎么更新”。
4.1 数据落在哪儿
这里的 PostgreSQL 不是只存一份 workspace JSON。按业务边界拆开后,主要数据大致是:
| 数据 | 主要表 | 说明 |
|---|---|---|
| 用户与认证 | app_users、auth_sessions、auth_tokens、oauth_accounts |
会话表只存 token hash;密码使用 Argon2id 哈希 |
| 工作区与成员 | editor_workspaces、workspace_members、workspace_preferences、workspace_document_preferences |
成员角色、当前工作区和每个工作区的活动文档分开记录 |
| 文档内容 | editor_documents、editor_blocks、block_relationships、block_comments |
文档元数据、块字段、父子关系和评论按关系表保存 |
| 权限与分享 | document_permissions、document_share_links |
显式成员授权与分享令牌 HMAC |
| 历史 | document_versions、document_write_requests |
文档快照与 REST 保存幂等响应 |
| 协同 | yjs_room_snapshots、yjs_room_updates |
Yjs 房间快照与增量更新 |
| 后台与 AI | background_jobs、ai_generation_runs、ai_usage_daily、ai_document_embeddings、ai_document_index_states、user_ai_settings |
任务、用量、索引状态、embedding 和加密后的个人 AI 配置 |
附件二进制不放在文档 JSON 或 PostgreSQL 正文列里。数据库保存附件元数据和对象 key,实际文件放本地持久卷或 S3 兼容存储。API 下载时再按文档权限授权;匿名分享走短时签名代理。
浏览器端另有 IndexedDB:它用于本地工作区模式和离线缓冲,不是远程数据库的替代品。远程模式由 PostgreSQL 提供权威数据;协同离线更新先由 y-indexeddb 暂存,重连后再参与同步。当前没有完整的跨设备离线冲突合并产品流程。
我后来还给 Markdown 导入加了一个“无损逃生口”:Markdown 块。
CommonMark/GFM 里总会有无法映射到原生块的结构,比如 front matter、原始 HTML、Mermaid、脚注和未知扩展。我没有选择拒绝整份文档,也没有静默丢内容,而是新增 markdown 块,保存原始源码切片。Mermaid/公式按安全边界预览;原始 HTML、SVG、iframe、脚本只显示转义源码,不执行。
这是我对“不丢数据”的一次具体学习:导入器不必承诺把所有语法都变成原生块,但必须诚实保留不能理解的内容。
5. 第五步:我从 Yjs 学到协同不是“接上 WebSocket”
我把协同房间显式绑定到工作区和文档:
`workspace:${workspaceId}:document:${documentId}`
一个 Y.Doc 不是一大段文本,而是三层数据:
Y.Doc
├─ document-structure : Y.Map<DocumentStructureRecord> key = documentId
│ └─ { title, blocks: [{ id, type, parentId, children, comments, ... }], updatedAt }
│
├─ block-content : Y.Map<BlockContentRecord> key = blockId
│ └─ { content, richText?, checked?, updatedAt }
│
└─ block-content:<blockId> : Y.XmlFragment 字符级富文本
5.1 我为什么把结构和字符内容分开
- 结构层管标题、块顺序、父子关系、块类型、评论、任务字段。这些操作频率低、语义强,用
Y.Map存整份结构记录足够。 - 块内容层管每块的纯文本、
checked和整块富文本快照,按blockId分开,避免一个块的输入导致整篇文档结构重写。 - XmlFragment 层只给四类富文本块使用,由 TipTap Collaboration 扩展按字符维护。落库时优先从 Fragment 还原富文本,Fragment 为空或解析失败才回退到
block-content的整块记录。
如果整篇文档只有一个 XmlFragment,拖拽、类型转换、块级评论会很难组织;如果只有 Y.Map,文本输入就可能退化成整块覆盖。三层拆分是我在结构操作和字符输入之间做的折中。我不是一开始就知道这是“正确架构”,而是通过追问 AI、阅读 Yjs/Tiptap 的数据模型,再结合具体块操作逐渐确定边界。
5.2 我怎么验证重启后协同内容还在
PostgresYjsPersistence 做两件事:
- 每个房间加载时,先读
yjs_room_snapshots,再按顺序重放yjs_room_updates。 - 运行期间,每个 Yjs update 追加一行;当更新条数达到 100 或字节数达到 1 MB 时,把当前文档编码成 snapshot,并在一个事务里更新快照、删除增量。
增量以 base64 存在 PostgreSQL,按房间串行写入。这个设计不追求极致吞吐,追求的是“服务重启后房间内容还在”。
多实例之间我用 Redis Pub/Sub 传播 Yjs update 和 Awareness;用 COLLABORATION_REMOTE_ORIGIN 标记远端来源,避免收到的更新再次广播形成回环。学习这部分时,最有效的不是让 AI 直接补完整个协同服务,而是针对“本地 update 会不会被再次 publish”“快照和增量的顺序是什么”“事务中途失败会留下什么状态”这些问题逐个验证。
5.3 我意识到权限必须覆盖已建立的连接
协同连接不是“连上就完事”。升级 WebSocket 之前,服务端会:
- 校验 Origin 是否在
COLLAB_ALLOWED_ORIGINS里。 - 从 Cookie 取会话,找到用户。
- 解析房间名,校验工作区成员资格和文档写权限。
- 只有通过后才
handleUpgrade。
viewer 不会进入可写通道。权限变更时,PostgreSQL 的 LISTEN/NOTIFY 会广播 workspace_access_invalidated,协同服务收到后关闭对应工作区或用户的连接,关闭码是 4403。
权限回收不能只影响下一次 REST 请求。我通过 PostgreSQL LISTEN/NOTIFY 让已运行的协同服务关闭相关连接;否则 viewer 的前端按钮虽然隐藏了,已经建立的 WebSocket 仍可能继续写。这让我把“权限验证”从接口里的一个 if,提升成需要覆盖整个连接生命周期的设计问题。
5.4 我在双写和 OCC 冲突里学到的教训
这是我后期做的最大一次架构调整,也是一个真实的返工教训。
早期我让浏览器同时通过 REST 保存结构、通过 Yjs 同步实时内容。两条链路节奏不同,容易遇到 OCC 409,也会出现“Yjs 里有、关系表还没写”的窗口。AI 可以帮我解释 409 或列出一致性方案,但真正让我看见问题的,是沿着保存链路追踪谁在写、什么时候写、重试是否会重复写。
后来我把目标改为让协同服务成为 editor_documents 的唯一写入者:
- 协同服务监听房间的
update事件,经过 2 秒防抖、最长 10 秒延迟,把 Y.Doc 物化成EditorDocument。 - 物化时,以
document-structure为骨架,用block-content覆盖checked,富文本优先从 XmlFragment 字符级还原。 - Web 侧新增
POST /api/documents/:id/persist,通过内部接口POST /internal/rooms/:room/flush触发立即落库。 - 版本恢复和 AI 任务确认不走普通保存,而是通过
POST /internal/rooms/:room/apply把文档写回房间,再立即 flush。 - 内部接口用
X-Internal-Token保护。
这条链路目前仍由特性开关控制:COLLAB_MATERIALIZE=off、NEXT_PUBLIC_PERSIST_VIA_COLLAB=false 是默认值。开启后浏览器走 persist 接口;失败时前端回退到一次 OCC PUT。这个事实让我提醒自己:代码里“有这条路径”和“线上默认在走这条路径”是两件事。
所以严格说,“协同服务是唯一写入者”是代码已具备、但默认没有完全启用的架构,不是当前默认运行状态。我需要把这个差别明说,而不是用一个漂亮的架构图盖过去。
5.5 我还没有解决的协同粒度问题
只有四类富文本块是字符级 CRDT。表格、看板、附件、代码块等仍然是 Y.Map 上的整块记录,按 updatedAt 做 LWW(最后写入者胜)。
这意味着:两个人同时改同一张表格的不同单元格,不保证字符级合并;更准确地说,协同层会把整块记录作为一次更新合并,业务层再用 updatedAt 决定是否覆盖。对当前产品来说可以接受,但它不是“所有块都 CRDT”。
另外,updatedAt 来自客户端时间。它在这里主要用来减少无意义的写入,不是严格的可信时钟。真正的关系型并发控制仍然靠文档 revision。
6. 第六步:从一次 409 学会乐观并发控制
REST 保存的协议是:
type SaveDocumentRequest = {
document: EditorDocument;
expectedRevision: number;
requestId: string;
};
服务端在一个事务里做这些事:
- 校验用户对文档有写权限。
SELECT ... FOR UPDATE锁定工作区和文档行,读当前revision。- 查
document_write_requests,如果requestId已存在,直接返回第一次成功时的响应。 - 比较
expectedRevision,不一致就回滚并返回 409。 - 计算服务端
updatedAt,取“当前时间”和“原值 + 1”的较大值。 - 更新标题、模板、置顶和
revision。 - 读取当前块、关系、评论,按规范化字段算差量:新块 INSERT、变化块 UPDATE、缺失块 DELETE。
- 写入去重的历史快照。
- 把完整响应写进
document_write_requests。 - 提交。
冲突响应长这样:
{
"code": "document_revision_conflict",
"error": "文档已被其他成员更新,请重新加载后再提交",
"revision": 12,
"snapshot": { "access": {}, "document": {}, "revision": 12 }
}
客户端的行为也被明确约束:
- 加载时记录
revision作为基线。 - 每次逻辑保存生成一个
requestId,同一次保存的网络重试复用同一个 ID。 - 保存成功后用服务端返回的文档、时间和新 revision 更新基线。
- 收到 409 时保留本地未保存内容,把状态置为冲突,让用户显式重新加载;不能用冲突快照直接覆盖本地编辑。
我为什么不用“最后写入者胜”?
因为整篇文档覆盖会静默丢掉另一个成员刚加的块。OCC 至少会把冲突暴露出来。为什么不用 CRDT 替代 REST 保存?因为关系型数据还要服务历史版本、权限过滤、AI 索引和结构化查询,CRDT 不是这些场景的权威存储。
这里是我认为项目最重要的一次架构学习:实时协同使用 CRDT,关系型持久化仍有 OCC + revision。它们解决不同的问题,不能指望一种机制替代另一种;要把两者边界写清楚,否则就会出现双写覆盖。
7. 第七步:我不再把权限理解成“页面上隐藏按钮”
做到多人协同后,我开始系统地补权限。最初看起来只是三个角色、几条判断,真正落到代码里才发现:REST、文件、WebSocket、匿名分享和 AI 检索都是不同入口。任何一个入口漏掉校验,前端做得再完整也没有意义。
我用 AI 梳理权限时,不再只问“这个角色能做什么”,而是把问题改成:同一份文档可以从哪些入口被读取或修改?权限撤回后,已经建立的连接和已经生成的链接怎么办? 这样得到的不是一张 UI 按钮表,而是一组必须由服务端守住的边界。
工作区层有三个角色:
| 角色 | 能力 |
|---|---|
| owner | 管理工作区、邀请、调整角色、移除成员、转让所有权、删除/恢复工作区、管理分享链接 |
| editor | 编辑有写权限的文档,不能管理成员和工作区 |
| viewer | 只读,不能写入,也不能加入可写协同通道 |
文档层有三种访问策略:
workspace:继承工作区成员权限。private:仅作者、显式授权成员和工作区 owner 可访问。link:匿名只读分享链接。
我把访问判定收敛成明确优先级:工作区 owner 最高,其次是文档作者,然后是显式 document_permissions,最后才看工作区成员身份。最终只向调用方暴露 canRead、canWrite、canManage 以及 role、source。这样 API、文件代理、协同服务和 AI 不必各自解释一遍角色。
匿名分享让我进一步意识到,“知道 URL”本身就是一种凭据,所以我把能力限制得比较克制:
- 默认 24 小时,可选 1 小时、7 天、30 天,或最长 365 天的自定义时间。
- 原始令牌是 256 bit,只在创建/重新生成时返回一次。
- 数据库只存域隔离的 HMAC。
- 未知令牌返回 404,已撤销/过期返回 410。
- 匿名页面只拿只读快照,不加载工作区导航、成员、评论、历史、任务元数据或 Yjs。
- 附件通过 5 分钟有效期的签名代理访问,不暴露对象存储 key。
最后我把同一套文档访问判定复用到 REST API、文件下载、协同 WebSocket 和 AI 检索。我的结论很简单:前端隐藏按钮只能改善体验,不能构成权限。
7.1 我为什么在 HTTPS 之外又封装了一次登录凭据
登录和注册请求不会把明文密码直接放进普通 JSON。浏览器先调用 POST /api/auth/credential-challenge,取得短时挑战和服务端公钥,再用 RSA-OAEP-256 把凭据封装成 JWE。服务端校验用途、有效期和一次性消费状态后解密,最终只把 Argon2id 哈希写入用户记录。
我需要特别说明:这不是 HTTPS 的替代品,也不是“多加一层加密就绝对安全”。它解决的是更窄的问题——减少明文凭据意外进入应用日志、代理采样或错误追踪的机会。会话仍由 HttpOnly、SameSite Cookie 承载,服务端只存 session token 的哈希;可选 GitHub OAuth 走 Arctic。做安全设计时,AI 很容易给出一串技术名词,我学到的是必须继续追问:它防的是哪一种风险,又明确不防什么。
8. 第八步:我把“失败后再试”做成了明确的状态机
邀请邮件和 AI 索引不能一直占着请求,也不能因为进程重启就消失。我一开始把后台任务理解成“丢给另一个进程执行”,后来才发现真正难的是领取竞争、幂等、租约、退避、终态和敏感载荷。
在这一部分,AI 对我最有帮助的不是生成 worker 循环,而是帮我逐项枚举故障窗口:任务执行成功但状态没提交怎么办,worker 领到任务后崩溃怎么办,同一个邀请重试会不会发多封邮件。带着这些问题,我才把 background_jobs 做成显式状态机:
background_jobs 表的核心字段:
| 字段 | 作用 |
|---|---|
type |
任务类型,如 workspace-invite.delivery、ai.document-index |
payload |
结构化载荷,敏感字段单独加密 |
dedupe_key |
全局唯一幂等键 |
status |
pending、running、succeeded、failed、cancelled |
attempts / max_attempts |
已执行次数 / 上限 |
available_at |
下次可领取时间 |
locked_by / lease_expires_at |
租约 |
last_error_code / last_error_summary |
脱敏错误 |
worker 用 FOR UPDATE SKIP LOCKED 领取任务,并按 available_at, created_at, id 排序,避免旧任务长期被新任务挤压。租约是 60 秒;失败后的间隔依次为 5 秒、30 秒、2 分钟、10 分钟;邀请邮件最多尝试 5 次。能判定为永久错误的任务直接失败,不机械耗尽重试次数。
我还给每种任务留了终态回调,并让它和任务状态在同一个事务里提交。例如邀请最终投递失败时,邀请本身的 delivery_status 也会原子更新为 failed,避免任务已经失败、业务页面却永远显示 pending。
敏感载荷用 AES-256-GCM 加密,密钥从 AUTH_HASH_SECRET 通过独立 HKDF context 派生,AAD 绑定 jobId 和任务类型。邀请任务只保存工作区 ID、邀请 ID、预期令牌哈希和加密令牌,不复制邮箱、邀请人姓名或工作区名称;worker 执行时再从数据库读取展示字段。这里我学到,后台任务表同样是数据边界,不能因为“只有内部服务会读”就把敏感信息整份复制进去。
这套方案仍然不是严格 exactly-once。SMTP 已接受邮件、数据库成功状态尚未提交时,如果进程崩溃,重试仍可能产生重复邮件。我只能用唯一任务、稳定 Message-ID、邀请行锁和成功状态复核缩小窗口。与其让 AI 给出一个听起来完美的“一次且仅一次”,我更愿意把无法消除的失败窗口写清楚。
9. 第九步:真正把 AI 接进产品后,我反而不再先聊模型
因为整个项目都在借助 AI 开发,我也想把 AI 能力放回产品里。但真正开始实现后,我发现“调用一个模型接口”只是最短的一段代码。更耗精力的是:哪些内容可以发给模型、谁来付费、如何限流和记账、用户取消后怎么办、引用的文档是否仍有权限。
这也改变了我借助 AI 开发 AI 功能的方式。我不再先让它写 Provider SDK,而是让它和我一起列输入边界、运行状态、失败码和权限变化,再回到仓库逐项确认已有能力能否复用。
9.1 我为什么拆成个人 Chat 和共享 Embedding 两条配置链
我把 AI 配置拆成两条:
- 个人 Chat 配置:每个登录用户在自己的设置里维护 Base URL、API Key、模型。Key 用 AES-256-GCM 加密,密钥由
AUTH_HASH_SECRET通过独立 HKDF context 派生,AAD 绑定user_id和版本号。服务端只返回“是否已配置”和末四位提示。 - 共享 Embedding 配置:由部署者的环境变量提供,文档索引和问题向量化共用。原因是向量空间必须一致,不能让每个用户用自己的 embedding 模型。
费用归属也随之分开:改写、任务提取和问答答案使用用户自己的 Chat Key;文档索引与问题向量化使用项目共享的 Embedding Key。
这不是单纯的配置形式问题:全部使用项目 Key,部署者很难控制成本;全部使用用户 Key,同一工作区的向量又可能来自互不兼容的模型空间。
9.2 我先定义一次 AI 运行的生命周期
我把一次生成请求固定成下面这条编排链:
会话认证
-> 工作区/文档授权
-> 输入校验
-> Redis 短窗口限流 + 并发流限制
-> PostgreSQL 用户/工作区配额预留
-> 创建 run(幂等键)
-> 调用 Provider
-> 结算实际用量
-> 释放剩余预留
运行记录放在 ai_generation_runs,配额放在 ai_usage_daily,以“日期 + user|workspace + 主体 ID”为主键。预留、结算和取消都以 run ID 幂等,避免重试或并发终态重复扣减。
取消接口是 DELETE /api/ai/runs/[runId]。当前进程先通知 AbortController,随后把运行标记为 cancelled 并结算;如果成功、失败或另一个取消事务已经先提交,就返回数据库中的真实终态。Compose 默认只有一个 Web 实例,所以进程内取消可以立即生效;多 Web 实例下,另一个实例里的 Provider 请求只能在完成或超时后观察到取消。跨实例即时中断需要额外的 Redis Pub/Sub,我还没有实现。
9.3 我先做三类受控能力,而不是通用 Agent
我把第一阶段能力限制为文本处理、任务提取和有依据的工作区问答。它们都遵守同一个原则:模型可以生成候选,但不能绕过现有权限和写入流程。
文本工具(rewrite / shorten / expand / summarize / translate):
- 只允许单个富文本块内的文本选区。
- 客户端先等保存屏障完成,记录文档 revision、Unicode code point 范围和文本哈希。
- 服务端从持久化结构化正文重新计算块纯文本,按 code point 验证范围和 SHA-256 哈希。
- 模型只收到服务端切出的选区文本。
- 结果完整生成并通过控制字符、换行、空结果和长度校验后,才发
result。 - 客户端应用前再次检查 revision、块 ID、选区范围和文本是否一致;一致才一次性替换,并进入现有撤销栈。
- 协同或本地编辑改变了目标时,结果保留在工作台,只提供复制和手动替换,不自动覆盖。
这条链路真正让我理解了“source changed”不是一个边角错误。AI 生成可能持续数秒,这期间用户和协作者都可能继续编辑。于是我在调用前后都校验 revision、块、选区和文本哈希,把模型结果当候选,而不是当可以无条件写回的补丁。
任务提取:
- 从当前选区或整篇文档提取候选,供应商按 JSON Schema 返回。
- 服务端限制候选数、字段长度和枚举,但不信任模型给的负责人或日期。
- 用户确认时,服务端重新校验写权限、成员、日期、revision 和批量大小,任一失败整批不写入。
- 确认写入走既有文档变更流程,AI 运行成功不等于任务已经写入。
我刻意把“提取成功”和“写入成功”分成两个动作。模型提供负责人或日期不代表这些字段合法;只有用户确认后,服务端才重新检查成员、日期、revision 和写权限,并以整批事务提交。
工作区问答:
- 文档保存后投递
ai.document-index后台任务。 - worker 按块切分纯文本,块大小 1200 字符、重叠 160 字符,标题会拼到每个 chunk 前面。
- 内容哈希没变就跳过 embedding。
- 在事务里校验文档 revision,删除旧 chunk,插入新 chunk,并写索引状态。
- 问答时先用共享 Embedding 向量化问题,取当前工作区的候选,算余弦相似度,阈值 0.35,最多 8 个来源。
- 对每个候选重新做文档读权限校验,再从权威存储读正文。
- 没有索引但有待处理文档时返回
indexing;没有可信依据时返回no_grounding,两种情况都不调用聊天模型。
这一部分我最在意的不是“总能回答”,而是“没有依据时不要编”。所以 indexing 和 no_grounding 都是正常产品状态,不是非得吞掉再让模型自由发挥的错误。每个候选在回答前还会重新鉴权,避免索引里残留的旧内容越过当前权限。
9.4 我把流式输出也当成协议来设计
三个生成接口都返回 text/event-stream。为了避免前端靠猜测维护状态,我固定了事件顺序:
run -> delta* -> result -> usage -> done
任务提取可能没有 delta,问答还会发送 sources。run 必须是第一个业务事件;一次请求只能有一个终态:done 或 error。HTTP 状态码只表达流开始前的错误;响应头发出后,失败只能通过 SSE error 事件传递。
我给客户端暴露稳定错误码,例如 ai_disabled、resource_not_found、source_changed、permission_changed、input_too_large、rate_limited、quota_exhausted、provider_unavailable、invalid_model_output、provider_timeout;Provider 正文、堆栈、密钥和 URL 不进入响应。这里的经验是:流式接口不是“不断吐字符串”,它同样需要可测试的顺序、唯一终态和安全的错误边界。
9.5 我回头对照设计稿,发现这些能力还没有落地
设计稿写过 pgvector、全文/向量混合召回、索引版本和断点回填。回到代码逐项检查后,真实状态是:
- 没有 pgvector。
- 没有服务端全文检索,也没有混合召回。
- 没有索引版本表,只有
embedding_model字段和当前 revision 的索引状态。 - 没有
db:reindex-ai之类的回填 CLI。 - 候选集是全工作区加载后在 Node 里算余弦,没有 ANN 索引。
- 没有答案缓存。
- 没有跨实例取消传播。
这些都是明确的阶段性缺口。当前方案只按小团队、文档量有限的场景设计,不能被描述成“生产级语义检索”;实际是否可用还取决于 Provider 与部署配置。这次核对也让我看到 AI 辅助开发的一个常见风险:设计文档很容易比代码完整,如果复盘时只读设计稿,就会无意中夸大交付状态。
10. 第十步:接口多起来以后,我开始先定约定再加端点
AI 可以很快帮我生成一个 Route Handler,但接口数量多起来以后,真正决定维护成本的是资源命名、状态码、错误结构、权限和日志约定。我把 API 大致按资源组织为:
| 域 | 主要端点 |
|---|---|
| 认证 | POST /api/auth/register、POST /api/auth/verify-email、POST /api/auth/password/forgot、POST /api/auth/password/reset、GET/POST/DELETE /api/auth/session、GET /api/auth/oauth/github |
| 凭据挑战 | POST /api/auth/credential-challenge |
| 工作区 | GET/POST /api/workspaces、GET/PATCH/PUT/DELETE /api/workspaces/:id、POST /api/workspaces/:id/select |
| 成员 | GET /api/workspaces/:id/members、PATCH/DELETE /api/workspaces/:id/members/:memberId、POST /api/workspaces/:id/leave、POST /api/workspaces/:id/ownership-transfer |
| 邀请 | GET/POST /api/workspaces/:id/invites、DELETE /api/workspaces/:id/invites/:inviteId、POST /api/workspaces/:id/invites/:inviteId/resend、GET /api/workspace-invites、POST /api/workspace-invites/resolve、POST /api/workspace-invites/accept、POST /api/workspace-invites/decline |
| 文档 | POST /api/workspaces/:id/documents、DELETE /api/workspaces/:id/documents/:documentId、`GET |
| 文档权限 | GET/PATCH /api/documents/:documentId/permissions、GET/POST/DELETE /api/documents/:documentId/share-links |
| 历史 | GET/POST /api/workspaces/:id/history/:documentId |
| 文件 | POST /api/files、GET /api/files/[...key]、GET /api/shared-files/:shareId/:keyToken |
| 分享 | GET /api/shared-documents/:token |
| 交换 | POST /api/workspaces/:id/markdown-import、GET /api/workspaces/:id/documents/:documentId/markdown-export、POST /api/workspaces/:id/documents/:documentId/block-paste |
| AI | GET /api/ai/capabilities、DELETE /api/ai/runs/:runId、GET/PUT/DELETE /api/ai/settings、POST /api/ai/settings/models、POST .../ai/transform、POST .../ai/ask、POST .../ai/tasks/extract、POST .../ai/tasks/commit |
| 运维 | GET /api/health、GET /api/ready、GET /api/metrics |
在逐个实现端点之前,我先固定了几条约定:
- 业务错误统一返回
{ code, error, retryable?, retryAfterSeconds?, revision?, snapshot? }。 - 权限不足和资源不存在经常统一成 404,避免通过响应差异探测文档是否存在。
- 写接口的冲突用 409,限流用 429,输入超限用 413,Provider 不可用用 502,超时用 504。
- 每个业务 API 都通过
withApiObservability(routeTemplate, handler)包装,模板是静态路由,不会把真实文档 ID 作为指标标签。 - 正常响应带
X-Request-Id,结构化日志记录方法、静态路由、状态码和耗时,不记录请求正文、Cookie、邮箱、令牌或文档内容。
协同服务还暴露两个只给 Web 使用的内部接口,并用 X-Internal-Token 保护:
POST /internal/rooms/:room/flush
POST /internal/rooms/:room/apply
它们不面向浏览器,也不进入公开 API 文档。把内部接口明确标出来,比仅仅依赖“前端不知道这个地址”可靠得多。
11. 第十一步:我开始为“怎么知道它坏了”写代码
系统拆成三个运行时后,只在本地看终端输出已经不够。我把可观测性拆成三层:
- 存活/就绪:
/api/health只表示进程能处理请求;/api/ready在数据库模式下执行受超时保护的SELECT 1,失败返回 503。 - 指标:
/api/metrics用 Bearer token 保护,token 比较用timingSafeEqual。标签只允许有限集合,比如method、route、status_class、state,不使用用户、工作区、文档、请求 ID 或错误文本。 - 日志:一行 JSON,包含
event、level、service、requestId、method、route、status、durationMs。未处理异常只记录固定errorCode,不记录Error.message和堆栈。
指标覆盖 HTTP 请求数、耗时直方图、处理中请求、未处理异常、PostgreSQL 连接池状态,以及协同和 AI 的专用状态;Grafana 面板按 Web、协同、Worker 和 AI 工作台拆分。我没有把用户 ID、工作区 ID、文档 ID、请求 ID 或错误文本放进指标标签,避免高基数和敏感信息泄漏。
部署仍然选择 Docker Compose,并明确启动依赖:
postgres / redis 健康
-> migrate 完成
-> worker / web / collaboration 启动
-> prometheus 抓取
-> grafana 启动
真正部署时,我需要继续守住这些边界:
- Web、Worker、Collaboration 必须使用同一套
DATABASE_URL、REDIS_URL、AUTH_HASH_SECRET和 AI 配置。 METRICS_TOKEN、AUTH_HASH_SECRET、GRAFANA_ADMIN_PASSWORD必须是高熵随机值。- 反向代理必须关闭 SSE 缓冲和缓存;Caddy 用
flush_interval -1,Nginx 至少proxy_buffering off、proxy_cache off。 - 附件卷、PostgreSQL 卷、Redis 卷都要备份;工作区永久清理会先删对象存储前缀,再删数据库行。
- 当前 Compose 是单机拓扑,不提供跨机器高可用。
12. 第十二步:AI 能帮我补测试,但不能替我宣称“已经验证”
AI 很适合根据接口和失败分支补测试样例,也能帮我发现重复请求、权限变化、流中断这类容易漏掉的场景。但它同样会制造一种错觉:测试文件越来越多,好像系统自然就更可靠了。
仓库当前大约有 200 个 Vitest 测试文件和 15 个 Playwright spec。我按下面几层组织它们:
- 纯函数/模型:块操作、批量操作、选择、剪贴板、Markdown 转换、富文本规范化。
- 服务端单元:权限判定、OCC 保存、任务队列、加密、限流、AI 编排、SSE 解析、错误映射。
- 组件测试:编辑器、侧栏、设置、分享、历史、成员、AI 面板。
- PostgreSQL 集成:真实数据库上的并发保存、配额、邀请、生命周期、索引。
- E2E:认证、协同、文档权限、分享、Markdown 交换、多块操作、可观测性、工作区删除与邀请。
- 假 Provider:确定性的流、结构化输出、慢响应、503、损坏协议,避免 CI 依赖真实模型。
不过,文件存在、测试能启动、目标环境验证通过,是三件不同的事。我必须把两个现实问题写清楚:
pg-mem能覆盖一部分 SQL 行为,但不能替代真实 PostgreSQL 的锁、SKIP LOCKED、并发事务和扩展行为。真实 PG 测试需要TEST_DATABASE_URL;没有配置时会明确跳过,跳过不等于通过。- 仓库里的交付记录多次写明“按当前指令未执行测试、构建或浏览器验证”。因此我只能说明测试覆盖了哪些意图,不能据此宣称全量回归全绿。写这篇文章时我也没有在目标环境重新执行完整测试。
这是我使用 AI 后很看重的一条证据规则:生成了测试,不等于运行了测试;运行通过了替身测试,不等于真实依赖已经验证;一次通过,也不等于生产环境已经验收。
13. 第十三步:我把“还没做完”也当成交付内容
对我来说,这一节和架构图同样重要。AI 能很快生成完整方案,设计稿也很容易写到未来几个阶段;如果不回到代码、默认配置和验证记录逐项核对,就会把“讨论过”“代码路径存在”“默认启用”“完成验收”混成一件事。
因此我把状态至少分成四类:已实现并默认启用、已实现但默认关闭、部分实现、未实现。下面这些缺口不是自我否定,而是给下一轮开发留下可信起点。
13.1 我还没有补齐的产品能力
| 能力 | 状态 | 说明 |
|---|---|---|
| 页面树 / 子页面 / 面包屑 | 未实现 | 文档是平铺列表,只有块级 parentId/children,没有文档级父子关系 |
| 服务端全文检索 | 未实现 | 快速搜索是前端对当前已加载工作区的内存过滤,没有 PostgreSQL FTS 或搜索 API |
| 反向链接 | 未实现 | 没有“哪些页面引用了当前页面”的关系索引 |
| 通知中心 | 未实现 | 没有通知表、通知 API、未读数或跳转定位 |
| @提及通知 / 任务分配通知 | 未实现 | mention 只是富文本里的原子节点,不会产生站内通知或邮件 |
| 账号设置 | 未实现 | 没有修改显示名、修改密码、变更邮箱、查看/撤销其他会话、注销账号、数据导出 |
| 分享密码 / 下载限制 / 域名白名单 | 未实现 | 分享链接只有只读和过期时间 |
| 历史版本对比 | 部分实现 | 有快照和恢复,没有 diff/对比 UI,也没有命名版本 |
| 工作区配额 / 文件配额 | 未实现 | 有单文件 20 MB、Markdown 2 MB、ZIP 解压 100 MB 等硬限制,但没有可配置的工作区级配额 |
| 审计日志查看 | 部分实现 | 审计事件会写入,但没有管理端查看界面 |
| 企业能力 | 未实现 | SSO/SAML/SCIM、DLP、计费、插件市场、开放平台都不在范围 |
| 移动端 / 离线多端同步 | 未实现 | 有 IndexedDB 本地模式和 y-indexeddb 离线缓存,但没有原生移动客户端或跨设备离线合并 |
13.2 协同与持久化仍有这些边界
| 能力 | 状态 | 说明 |
|---|---|---|
协同服务单写 editor_documents |
已实现,默认关闭 | COLLAB_MATERIALIZE=off、NEXT_PUBLIC_PERSIST_VIA_COLLAB=false;开启后浏览器走 persist,失败回退 OCC PUT |
| 全块类型字符级 CRDT | 未实现 | 只有段落、标题、引用、待办有 XmlFragment;表格、看板、附件等是整块 LWW |
| 自动三方合并 / 冲突合并 UI | 未实现 | OCC 冲突只返回 409 和当前快照,要求用户重新加载 |
| 多实例即时取消 AI 流 | 未实现 | 只有当前 Web 进程内的 AbortController 立即生效 |
| 协同物化失败可观测 | 部分实现 | 物化失败会吞掉异常,等下一次编辑重试;没有独立的失败指标或死信队列 |
13.3 AI 设计稿中还没有落地的部分
| 设计稿里的能力 | 实际状态 |
|---|---|
| pgvector / ANN 索引 | 未实现,embedding 以 JSON 数组存储,Node 内全量余弦 |
| 全文 + 向量混合召回 | 未实现,只有向量召回 |
| 索引版本表 / 模型切换 / 原子激活 | 未实现,只有 embedding_model 字段和当前 revision 状态 |
断点回填 / db:reindex-ai |
未实现 |
| 引用白名单与引用失效处理 | 部分实现:服务端会重新鉴权并生成来源,但没有设计稿里完整的引用生命周期 |
| 答案缓存 | 未实现,按设计也不缓存 |
| 跨用户/跨工作区问答 | 不支持,按设计限制在当前工作区 |
| 本地 IndexedDB / 匿名分享中的 AI | 不支持,按设计只对数据库会话开放 |
| AI 自动执行不可逆操作 | 不支持,任务必须用户确认后才写入 |
除了代码缺口,还有两个运行层面的现实:
AI_ENABLED默认是false。没有完整的共享 Embedding 配置、Redis 和供应商审核,我不会开放 AI 能力。- 开发日志记录过:当时使用的 Provider 对
/embeddings返回 404,/models也没有可用的 embedding 模型。在那套环境里应该保持AI_ENABLED=false,否则后台索引会持续失败。这不是代码实现缺口,却会直接决定 AI 在实际部署中是否可用。
13.4 安全与运维欠账
| 能力 | 状态 |
|---|---|
| CSP / 安全响应头 | 未实现(next.config.mjs 没有配置) |
| 上传文件类型深度校验 / 恶意文件隔离 | 部分实现:有大小和 MIME 记录,没有病毒扫描或隔离区 |
| 工作区级请求大小 / 文档大小上限 | 部分实现:有若干硬编码限制,没有统一配额 |
| 账号删除与数据保留策略 | 未实现 |
| 备份恢复演练自动化 | 文档有步骤,没有自动化演练 |
| 多租户隔离 / 高可用 | 不支持,定位是单团队自托管 |
13.5 我能确认到什么程度
- 我核对了仓库中的实现、设计稿、功能说明和开发记录,因此可以描述代码里有哪些链路、默认开关是什么、设计与实现有哪些差距。
- 仓库内测试文件数量不等于全量通过;真实 PostgreSQL 测试需要
TEST_DATABASE_URL,未配置会跳过。 - 多份 AI 阶段交付记录明确写着未执行 Vitest、TypeScript、构建或浏览器验证。
- 历史 PRD 还记录过完整 Playwright 回归曾在成员导航、协作连接和旧附件场景存在未通过项。当前仓库虽然有 15 个 E2E spec,但我写本文时没有在目标环境重新执行完整回归。
所以这篇文章能承担的是工程复盘,而不是生产验收报告。我宁可留下一个明确的“未验证”,也不想用“理论上没问题”替代证据。
14. 回头看,这个项目真正教会我的几件事
14.1 结构化数据和 CRDT 不是二选一
我最初很容易把问题理解成“用了 Yjs,是否还需要关系型保存”。实际答案恰好相反:Yjs 负责实时合并,PostgreSQL 负责权威查询、权限、历史、AI 索引和后台任务,两者之间必须有明确的权威边界。
我在这里走过双写的弯路,后来才增加协同服务单写路径,并保留 OCC 降级。得到的经验是:只要存在两个写入者,就必须定义谁是权威、什么时候落库、失败后怎么办。 否则功能表面可用,数据会在边角场景出现分叉。
14.2 权限应该是一项可复用能力,而不是散落的 if
我把文档访问判定收进 DocumentAuthorizationService,让 REST、文件、协同和 AI 复用。新增入口时,不必重新发明一套角色逻辑,也更不容易出现“API 拒绝了,但 WebSocket 还能写”的漏洞。
分享链接也遵循同一边界:令牌只存 HMAC,匿名访问只拿快照,附件走短时签名代理,不把内部 object key 暴露给浏览器。
14.3 选队列先看事务边界,不先看流行度
我不再把 PostgreSQL 队列看成“更土”的选择。邀请、审计和任务需要原子提交时,它比 Redis 队列少一条 outbox 链路,也少一类双写故障。吞吐真的成为瓶颈时再换,比一开始为了“架构感”引入额外中间件更适合这个项目。
14.4 AI 功能的第一性问题不是模型能力
实现 AI 工作台后,我最常考虑的变成了:哪些内容能进模型,用户是否有权限,这次调用会不会重复扣费,流断了怎么取消,权限变化后旧引用还能不能打开。相比之下,模型调用本身反而简单。
所以我的顺序是先建立配额、幂等、取消、稳定错误和权限边界,再开放文本工具、任务提取和问答入口。模型输出永远只是候选,不能天然拥有写权限。
14.5 功能开关也是架构的一部分
COLLAB_MATERIALIZE、NEXT_PUBLIC_PERSIST_VIA_COLLAB、AI_ENABLED 这些开关让我能保留旧链路、逐步启用新能力并在失败时降级。代价是代码里会同时存在多条路径,所以测试、部署和文档都必须写清默认值。否则连我自己回看时,都可能把“代码已实现”误解成“运行时已启用”。
14.6 如果重来一次,我会更早做这些事
- 更早设计协同单写路径,而不是先让 OCC 和 Yjs 长期双写;当前单写链路仍是默认关闭,我会先完成真实环境验证再考虑切换默认值。
- 更早定义块级 CRDT 的边界,明确哪些块是字符级、哪些是整块 LWW,避免读者和用户误解。
- 更早引入真实 PostgreSQL 的并发测试,而不是主要依赖
pg-mem。 - 在 AI 之前先把服务端全文检索做掉;没有 FTS,问答的候选质量和可解释性都会受限。
- 把“未实现清单”作为每个里程碑的交付物,而不是等复盘时补。
- 在让 AI 大批量生成实现前,先固定验收证据:需要跑哪些命令、依赖什么真实服务、哪些结果必须手工检查。
15. 如果你想沿着这条路线继续读或运行项目
完整环境用 Docker Compose:
cp .env.example .env
# 至少设置 POSTGRES_PASSWORD、AUTH_HASH_SECRET、METRICS_TOKEN、GRAFANA_ADMIN_PASSWORD
pnpm auth:keygen
docker compose config --quiet
docker compose up -d --build
默认入口:
- Web:
http://localhost:3000 - 协同:
ws://localhost:1234 - Grafana:
http://127.0.0.1:3001
本地开发可以用 pnpm dev:fullstack 同时启动 Web、协同和 worker。AI 能力默认关闭;本地联调可使用 pnpm dev:ai-fake。个人 Chat 配置在用户设置中维护,共享 Embedding 则由部署环境变量提供,两者不是同一条配置链。
更完整的部署、环境变量、备份恢复和反向代理配置在 docs/deployment.md,功能边界在 docs/features.md,产品阶段和里程碑在 docs/prd.md。
如果想按我的学习路径读代码,我建议先看 src/features/editor/model/block.ts 理解内容模型,再看 src/server/postgresDocumentStore.ts 里的关系型保存与 revision;接着读 src/server/yjsPersistence.ts 和 src/server/collaborationMaterializer.ts,比较 CRDT 与物化边界;然后看 src/server/documentAuthorization.ts 如何统一权限,最后再读 src/server/backgroundJobQueue.ts 和 src/server/ai/aiOrchestrator.ts。先理解数据和权限,再看 AI,会比从某个模型调用函数切入更容易建立全局认识。
最后
Nexus 不是一个“AI 帮我一次性做完的 Notion”,也不是一个什么都已验收的成品。对我而言,它更像一段持续拆问题、补证据和修正设计的学习过程:块模型如何落进关系表,CRDT 如何与 OCC 共存,权限如何贯穿 REST、文件、WebSocket 和 AI,后台任务如何处理重试,模型输出如何在不绕过权限的前提下进入产品。
AI 确实加快了我的学习和实现:它能随时解释概念、比较方案、枚举失败场景、辅助补测试,也能帮我快速把想法变成设计草稿。但这个项目让我更确定,开发者的价值没有因此消失,反而更集中在几个地方:缩小问题、选择约束、辨别证据、读懂关键链路,并对没做完的部分保持诚实。
如果只记住一句话,我希望是:不要把 AI 生成的完整答案,当成系统已经闭环的证据。 对协作文档来说,难点从来不只是把文本同步起来;对 AI 辅助开发来说,难点也不只是把代码生成出来。同步或生成之后,数据是否仍然可查询、可授权、可恢复、可验证,才是我在这次开发里真正学到的东西。