Studio 改了内容,Reader 为什么不会立即变化:ReadTao 的不可变发布设计
内容管理端保存成功,不应该等于线上内容立即被改写。ReadTao 在修订表和 Reader 之间增加了完整底本级的不可变 Release:发布事务固定每个段落采用的原文、拼音、译文和来源注文,再一次性切换当前版本指针。
前两篇已经走过 Content Studio 最长的一段路:
现在假设一部典籍的所有内容单元都已经整理完成,段落和内容层也全部批准。接下来的问题是:这些内容怎样安全地出现在 Reader?
本文涉及的 SQLAlchemy 模型、发布接口、公共读取接口和 OpenAPI 契约都可以在 ReadTao GitHub 仓库 中查看。
为什么不能让 Reader 直接读最新修订
最短的实现似乎是:Reader 查询每个段落最新的一条修订,Studio 保存以后刷新页面就能看到新内容。
但这个方案会让编辑行为直接变成线上行为。
例如我只修改了第一卷第 8 段原文:
- 新原文已经保存;
- 简体仍然基于旧原文;
- 拼音词元还没有重新生成;
- 现代汉语和英文仍是上一版;
- 这次修改还没有提交审校。
如果 Reader 分别读取各表里的“最新记录”,就可能拼出一份数据库里从未真正存在过的混合版本。
“每张表都查最新”并不等于“整部内容处于同一个可靠版本”。
Release 固定的是一次完整选择
ReadTao 使用 Release 表示一次整部底本发布。
它不是复制一份拼接好的大 JSON,而是固定这次发布选择了哪些稳定实体和具体修订。
其中:
Release保存底本、发布号、状态、说明、发布人和时间;ReleaseItem固定稳定segment_id与这次使用的segment_revision_id;ReleaseItemRendition固定简体、拼音、现代汉语和英文修订;ReleaseItemCommentary固定来源注文块的具体批准修订。
同一个 Segment 以后可以继续产生 v4、v5 修订,旧 Release 仍然指向当时发布的 v3。
Segment A
├── Revision v1 ── Release 1
├── Revision v2
└── Revision v3 ── Release 2
Release 1 始终读取 v1
Release 2 始终读取 v3这就是这里所说的“不可变”:发布记录存在期间不原地改写,而不是把所有历史都复制成互不关联的数据孤岛。
发布边界为什么是完整底本
ReadTao 当前不提供“发布这一段”或“发布这一章”。发布对象只能是一整个 Edition。
原因仍然是完整性。
如果《道德真經注》登记为四卷,却只发布第一、二、四卷,Reader 的目录和全书导出都会得到一部结构残缺的典籍。段落级发布更容易造成新旧内容混杂。
因此,每个底本都保存 expected_chapter_count。发布时,实际内容单元序号必须连续覆盖 1..N。
expected_numbers = list(range(1, edition.expected_chapter_count + 1))
actual_numbers = [int(division.order_key) for division in divisions]
if actual_numbers != expected_numbers:
issues.append("内容单元必须连续覆盖预计范围")对于没有章、卷、篇结构的短典籍,也可以把整篇作为唯一内容单元,把预期数量设为 1。
一次发布需要检查什么
发布接口首先锁定目标底本,然后收集一份结构化问题清单。
主要门禁包括:
- 来源和许可说明完整;
- 实际内容单元连续覆盖预计数量;
- 每个内容单元有独立来源文件和 SHA-256;
- 必需生成任务已经成功;
- 每个活动段落都有已批准的规范原文;
- 简体、拼音、现代汉语和英文都已批准;
- 所有派生层都基于当前最新原文;
- 拼音风险已经逐项确认;
- 来源分层没有待界定或覆盖问题;
- 来源注文已锚定并且最新修订已批准。
如果存在问题,Backend 返回 422 和具体段落、内容层、原因,Studio 可以直接把编辑者带回需要处理的位置。
{
"message": "发布检查未通过",
"issues": [
{
"message": "第 03 段的拼音风险尚未全部确认",
"segment_id": "...",
"kind": "pinyin"
}
]
}前端会提前展示完整度,但真正的发布门禁必须留在服务端。否则换一个客户端或直接调用接口,就可能绕过页面校验。
发布是一个数据库事务
检查全部通过以后,Backend 才开始创建快照。
核心代码可以抽象成:
edition = select_edition_for_update(edition_id)
issues = validate_whole_edition(edition)
if issues:
db.rollback()
raise PublishValidationError(issues)
release = create_release(status="preparing")
pin_approved_segment_revisions(release)
pin_approved_rendition_revisions(release)
pin_approved_source_commentaries(release)
supersede_previous_release(edition.current_release_id)
release.status = "published"
edition.current_release_id = release.id
write_audit_log(release)
db.commit()这些步骤在同一个事务里完成。任一步失败都会整体回滚,current_release_id 不会指向一份只写到一半的 Release。
发布失败不应该破坏旧版本
旧 Release 在新事务成功前一直保持可读。只有新快照已经完整写入,当前指针才会切换。发布失败时,Reader 继续读取原版本,而不是显示半成品或空页面。
current_release_id 是公开读取的单一入口
Edition 上有一个看起来很普通的字段:
current_release_id: UUID | NoneReader 的正常公共请求不查询最新草稿,而是从这个指针开始。
Studio 继续编辑时,只会产生新的修订,不会改动已经发布的映射。下一次发布成功后,指针才会从 Release 1 切到 Release 2。
如果需要预览或回看指定历史版本,公共内容接口也可以显式携带 release_id;普通书库和阅读路由仍然默认使用当前指针。
Reader 读取的是投影,不是另一套表
我没有为 Reader 再维护一套 reader_books、reader_chapters 和 reader_paragraphs 表。
Backend 根据 Release 关系构造两个主要只读投影:
| 投影 | 接口 | 主要用途 |
|---|---|---|
PublishedChapter | /chapters/{division_slug} | 正常阅读、相邻章节、当前章导出 |
PublishedBook | /content | 全书导出和打印预览 |
章节接口返回当前内容单元、完整目录、上一篇/下一篇和段落内容,不需要每次下载整部书。
整书接口返回当前发布版本的全部内容单元和段落,只在用户选择全书导出或打印时请求。
GET /api/public/works/{work_slug}
GET /api/public/works/{work_slug}/editions/{edition_slug}/chapters/{division_slug}
GET /api/public/works/{work_slug}/editions/{edition_slug}/content投影只是读取结构,不是新的事实来源。这样 Studio、Reader、导出和以后可能出现的搜索都围绕同一份 Release 工作,不需要同步两套数据库。
OpenAPI 怎样连接 FastAPI 和两个前端
公共投影和 Studio 工作台接口包含不少嵌套结构。如果前端自己猜字段,版本边界很快会失效。
ReadTao 通过 FastAPI 导出 OpenAPI,再生成 TypeScript 类型:
pnpm api:export
pnpm api:types平时直接运行:
pnpm api:generate
pnpm api:check生成文件位于:
packages/api-client/openapi.json
packages/api-client/src/schema.d.tsReader 和 Studio 都依赖 @readtao/api-client。接口字段发生变化时,类型检查会先暴露受影响的消费代码;api:check 还会发现后端契约改变后忘记更新生成文件的情况。
契约测试不能证明页面体验正确,但能保护 Studio、Backend 和 Reader 之间最基本的结构一致性。
向量化为什么不属于发布事务
发布完成后,Backend 会为这个 Release 创建“未开始”的索引记录,但不会自动调用 Embedding。
发布者需要在 Studio 显式点击“向量化当前版本”,Worker 才会读取这份不可变 Release 生成向量。索引失败也不会撤销内容发布。
这条边界保证普通阅读不依赖 Embedding Provider,也不要求每一部典籍都先向量化才能上线。
不可变发布解决的不只是回滚
实现这套模型以后,我得到的不只是“可以回到旧版本”。它还解决了:
- Reader 不会读取审校中的草稿;
- 同一次阅读不会混合不同修订;
- 导出内容能够标记明确发布号;
- RAG 向量可以回指具体
release_id和segment_id; - 发布失败不会破坏线上版本;
- 继续编辑不会改写历史证据。
对一个长期、低频维护的个人项目来说,这种稳定性比“保存后立刻上线”更重要。
现在,Studio 的工作已经完成,公共 API 也拿到了可靠快照。最后一篇进入读者真正看到的地方:一个古籍阅读器不只是排版:ReadTao Reader 的响应式设计与状态模型。