Skip to content

Studio 改了内容,Reader 为什么不会立即变化:ReadTao 的不可变发布设计

约 2531 字大约 8 分钟

ReadTaoFastAPIPostgreSQLOpenAPI

2026-07-11

内容管理端保存成功,不应该等于线上内容立即被改写。ReadTao 在修订表和 Reader 之间增加了完整底本级的不可变 Release:发布事务固定每个段落采用的原文、拼音、译文和来源注文,再一次性切换当前版本指针。

前两篇已经走过 Content Studio 最长的一段路:

  1. 从原始文件到来源分层和语义段落
  2. 从机器初稿到人工修订和审校

现在假设一部典籍的所有内容单元都已经整理完成,段落和内容层也全部批准。接下来的问题是:这些内容怎样安全地出现在 Reader?

本文涉及的 SQLAlchemy 模型、发布接口、公共读取接口和 OpenAPI 契约都可以在 ReadTao GitHub 仓库 中查看。

为什么不能让 Reader 直接读最新修订

最短的实现似乎是:Reader 查询每个段落最新的一条修订,Studio 保存以后刷新页面就能看到新内容。

但这个方案会让编辑行为直接变成线上行为。

例如我只修改了第一卷第 8 段原文:

  • 新原文已经保存;
  • 简体仍然基于旧原文;
  • 拼音词元还没有重新生成;
  • 现代汉语和英文仍是上一版;
  • 这次修改还没有提交审校。

如果 Reader 分别读取各表里的“最新记录”,就可能拼出一份数据库里从未真正存在过的混合版本。

“每张表都查最新”并不等于“整部内容处于同一个可靠版本”。

Release 固定的是一次完整选择

ReadTao 使用 Release 表示一次整部底本发布。

它不是复制一份拼接好的大 JSON,而是固定这次发布选择了哪些稳定实体和具体修订。

其中:

  • Release 保存底本、发布号、状态、说明、发布人和时间;
  • ReleaseItem 固定稳定 segment_id 与这次使用的 segment_revision_id
  • ReleaseItemRendition 固定简体、拼音、现代汉语和英文修订;
  • ReleaseItemCommentary 固定来源注文块的具体批准修订。

同一个 Segment 以后可以继续产生 v4v5 修订,旧 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 | None

Reader 的正常公共请求不查询最新草稿,而是从这个指针开始。

Studio 继续编辑时,只会产生新的修订,不会改动已经发布的映射。下一次发布成功后,指针才会从 Release 1 切到 Release 2。

如果需要预览或回看指定历史版本,公共内容接口也可以显式携带 release_id;普通书库和阅读路由仍然默认使用当前指针。

Reader 读取的是投影,不是另一套表

我没有为 Reader 再维护一套 reader_booksreader_chaptersreader_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.ts

Reader 和 Studio 都依赖 @readtao/api-client。接口字段发生变化时,类型检查会先暴露受影响的消费代码;api:check 还会发现后端契约改变后忘记更新生成文件的情况。

契约测试不能证明页面体验正确,但能保护 Studio、Backend 和 Reader 之间最基本的结构一致性。

向量化为什么不属于发布事务

发布完成后,Backend 会为这个 Release 创建“未开始”的索引记录,但不会自动调用 Embedding。

发布者需要在 Studio 显式点击“向量化当前版本”,Worker 才会读取这份不可变 Release 生成向量。索引失败也不会撤销内容发布。

这条边界保证普通阅读不依赖 Embedding Provider,也不要求每一部典籍都先向量化才能上线。

不可变发布解决的不只是回滚

实现这套模型以后,我得到的不只是“可以回到旧版本”。它还解决了:

  • Reader 不会读取审校中的草稿;
  • 同一次阅读不会混合不同修订;
  • 导出内容能够标记明确发布号;
  • RAG 向量可以回指具体 release_idsegment_id
  • 发布失败不会破坏线上版本;
  • 继续编辑不会改写历史证据。

对一个长期、低频维护的个人项目来说,这种稳定性比“保存后立刻上线”更重要。

现在,Studio 的工作已经完成,公共 API 也拿到了可靠快照。最后一篇进入读者真正看到的地方:一个古籍阅读器不只是排版:ReadTao Reader 的响应式设计与状态模型