#frontend#react-query#performance#ddd

Violet 首页资源接口的实现:统一发布物流与分层加载

violet
violet
2026年9月7日24 次阅读

首页看上去只是几个板块:站点介绍、最近更新、过去一年的创作轨迹、订阅入口,再加一个可以点下去的“印记”。如果只看组件,很容易把它理解成一次普通的接口整理。

真正动手后,我发现问题根本不在组件数量,而在首页一直没有自己的读取模型。文章、笔记、图集各自能分页,站点设置也能读取,归档接口还能按年份统计;这些接口单独看都没有错,但把它们同时交给首页,仍然回答不了几个很基础的问题:

  • “最近五条”究竟是三个来源各取一点再拼,还是全站严格有序的五条?

  • 过去十二个自然月的数据如果超过一页,前端拿到的是完整窗口,还是看起来完整的一页?

  • 图集有工作版本和已发布版本,首页应该跟着哪个标题变化?

  • 一个匿名访客连续点击、刷新,甚至并发发出请求时,印记数会不会重复增加?

  • 首屏中某个非关键板块失败,是否应该拖垮整个页面?

这次改造最终没有做一个“万能首页接口”。我把首页需要的公开数据收敛成三种资源:站点身份、发布物流、站点印记;再把首屏资源和延迟资源拆成两条加载链。后端负责跨内容类型的顺序与一致性,前端只负责按页面节奏消费,不再猜测全局事实。

本文沿着实际实现展开。涉及的主要入口是 publication_entries 投影、三类来源的事务写入、HMAC 复合游标、十二个月续取、匿名设备印记和 /feed.xml。文末会把运行验证、只由源码确认的行为、尚未解决的边界分开写,避免把“有测试文件”说成“已经在真实 PostgreSQL 上跑过”。

首页不是七个列表的拼接页

旧首页最多会为一次展示发出九个 HTTP 请求。请求多只是表象,更难处理的是每个接口都在暴露自己的领域形状。

旧来源

它原本回答的问题

首页真正需要的事实

文章列表

文章如何分页

它在全部公开内容中的位置

笔记列表

笔记如何分页

它在全部公开内容中的位置

图集列表

图集如何分页

当前已发布版本的标题与入口

系列列表

系列容器有哪些

不属于发布物流

推文列表

短动态有哪些

独立板块,允许延迟加载

公告列表

当前公告是什么

独立业务,不参与“最近创作”

归档年份与年份详情

某年有哪些文章

连续十二个月的完整发布分布

站点设置

管理端有哪些配置

可公开、稳定、已归一化的站点身份

前端把多个有限样本合并,只能得到“这些样本里的最新”,不能得到由服务端承诺的全局顺序。归档接口也有类似问题:它按年份和文章域设计,首页却想画一条跨文章、笔记、图集的连续时间线。即使九个响应都返回 200,页面仍可能展示一个语义上不完整的答案。

因此第一步不是减少请求,而是重新划边界。

资源

对外职责

明确不负责

GET /api/v1/site-identity

站名、站主、简介、头像、社交链接、订阅渠道、首页功能开关

管理端设置全集、密钥、OAuth 凭据

GET /api/v1/publications

文章、笔记、图集的统一公开顺序与时间窗口

系列容器、推文、公告、正文详情

GET/POST /api/v1/site-impressions

主动留下印记的去重设备数,以及当前设备是否已登记

浏览量、可信 UV、用户画像

这个边界很重要。系列是内容组织容器,不是一条独立发布;推文和公告有自己的展示节奏,也不该为了“统一”被硬塞进创作物流。统一读模型的目标不是把所有表压成一张表,而是给一个明确页面提供它真正需要的稳定事实。

用普通投影表承接全局发布顺序

跨三张来源表做 UNION ALL 并不难。难的是每次首页读取都要重复处理状态、软删除、图集版本和笔记标题派生,之后再排序、分页。查询能写出来,不代表它适合成为长期公开契约。

最终实现增加了一张很窄的 publication_entries

sql

它不是物化视图,也不是另一套内容主表。来源实体仍然拥有正文、状态机和业务规则;投影只保存首页、RSS 和其他公开消费者共同需要的六个字段。

kind + source_id 组成主键,因为不同来源理论上可以出现相同 UUID。route_key 隔离路由差异:文章和图集用 slug,笔记用 ID。featured 只允许文章为真,数据库约束直接拒绝“精选笔记”这类领域外状态。

迁移不只建表,还从已有公开来源回填:

  • 文章只选择 published、有发布时间、未软删除的记录;

  • 笔记优先使用显式标题,没有标题时从 HTML 去标签、合并空白,截取 48 个字符,最后才退到“无题笔记”;

  • 图集连接 published_revision_id 指向的版本,工作版本尚未再次发布时不会泄漏到首页;

  • 回填使用 ON CONFLICT (kind, source_id) DO UPDATE,重复执行仍会收敛到当前公开状态。

这里最容易被忽略的是图集。图集作者可以保存新的工作版本,但对外标题仍应来自已发布版本。若投影直接读取“最新编辑版本”,保存草稿就会悄悄改变首页和 RSS;这不是缓存问题,而是发布边界被破坏。

来源和投影必须一起提交

有了投影表,下一道风险是双写。先发布来源、后更新投影,只要第二步失败,首页就会缺条目;先写投影、后发布来源,则可能短暂暴露尚未发布的内容。

这次没有为单库同步写入引入事件总线、outbox 或异步补偿。三个来源和投影都在同一个 PostgreSQL 实例里,最短也最强的保证就是同一事务。

PostPublicationUnitOfWorkNotePublicationUnitOfWork 会在一个 GORM transaction 中重新构造来源仓储与发布物仓储。图集沿用自己的事务边界,并把发布物 writer 纳入同一回调。应用服务只通过事务对象访问两边,不能意外拿外层数据库句柄绕出去。

同步规则不是“发布时插入”这么简单:

来源动作

投影动作

首次发布

Upsert 当前公开字段

已发布内容改标题或路由

Upsert 新字段

转回草稿 / 取消发布

Delete

软删除

Delete

恢复且仍为公开状态

Upsert

图集只保存工作版本

不改投影

图集重新发布工作版本

Upsert 已发布版本字段

Upsert 让重复发布保持幂等;Delete 不要求调用方先判断投影是否存在。更关键的是失败语义:测试会安装一个强制拒绝 publication_entries 写入的数据库触发器,然后确认文章、笔记和图集的来源状态一并回滚。对于图集,失败后还要保持工作版本、已发布版本、版本号和媒体引用不变。只检查“返回了 error”远远不够,必须检查错误发生后公开事实没有撕裂。

为什么不用异步事件?异步方案适合跨库、吞吐隔离或允许最终一致的场景;这里的写入频率低,来源和投影同库,而且首页不能接受“文章已经发布但物流暂时看不到”。引入消息投递只会多出积压、重放和修复路径,并没有换来需要的收益。

回填之后还要能查账

事务只能保护接入事务之后的写路径,不能证明历史回填正确,也不能阻止未来新增代码绕过 unit of work。为此实现里还有一个只读一致性检查器。

检查器在 REPEATABLE READ、只读事务里取同一快照,通过 FULL OUTER JOIN 报告三类差异:

  • missing:来源公开,但投影不存在;

  • orphaned:投影存在,公开来源不存在;

  • drifted:两边都有记录,但 route、标题、发布时间或精选状态不同。

笔记标题不能只在 SQL 里做字符串比较,因为线上写入使用领域层的 DeriveNoteTitle。检查器读取原始标题与 HTML,再调用同一派生函数,避免迁移 SQL和运行时代码长期各维护一套规则。

命令 make check-publications 只输出 JSON 和退出码,不自动修。自动修复看似省事,实际会掩盖来源语义:遇到图集版本漂移时,工具必须先知道哪一版才应该公开。当前选择是让检查器做探针,让修复仍由明确的运维动作完成。

稳定分页不是把 offset 换成 cursor

统一物流需要一个全序。当前顺序是:

published_at DESC, kind ASC, source_id DESC

只按 published_at 不够。数据库时间精度很高,但批量迁移、脚本发布或测试数据仍可能同一时刻写入多条记录。kind 提供跨来源的确定顺序,source_id 再把同类型同时间的记录排成唯一顺序。

从上一页最后一条继续时,查询条件必须和三个排序方向完全对应:

sql

这里 kind>source_id 却用 <,不是笔误。前者升序,后者降序;复合游标本质上是在手写这组三元组的字典序“下一段”。仓储集成测试会故意插入同一发布时间的 article、gallery、note,锁住这个混合方向。

应用服务每次请求 limit + 1 条。如果多出来一条,截掉它并返回 has_more=true;下一游标取当前页最后一条,而不是那条探测记录。默认一页 20 条,最大 100 条。

游标中包含版本、发布时间、kind、source ID。它用 Base64URL 编码,但 Base64 不是防篡改机制,所以载荷后面还带 HMAC-SHA256 签名。服务端先做常量时间签名比较,再校验版本、kind、RFC3339Nano 时间和 UUID。客户端可以看见游标内容,却不能把时间改早来绕过查询边界。

下面是从该机制缩减出来的可执行程序。它不是生产文件的复制品,只保留“签名载荷”和“篡改后拒绝”两件事。

go

在 Go 1.26.5 下的实际输出:

text

签名游标解决的是完整性,不是快照隔离。两次翻页请求之间如果已有条目的 published_at 被修改,它可能跨过游标边界;这一点会在文末单独列为剩余限制。

十二个月窗口必须续取到结束

首页“足迹”展示最近十二个自然月,不是最近 365 天。前端以 UTC 计算当前月月初,再向前推十一个月,得到 [from, to) 半开区间。例如参考时间位于 2026 年 9 月时,窗口是:

text

服务端的 from 是包含下界,to 是不包含上界。相邻月份因此没有重叠,也不会在月末手写“23:59:59.999”后漏掉更高精度的时间。

足迹查询每页取 100 条,并持续使用 next_cursor,直到 has_more=false。如果服务端说还有下一页却没给游标,或重复给出已经见过的游标,前端直接报错,不会把半截数据伪装成完整足迹。这是一个刻意的失败策略:时间线可以显示失败和重试,但不能悄悄少画几个月的内容。

月份、季节和点位密度只属于展示模型。API 返回严格有序的原始发布物,前端再把它们投到最近十二个月;后端不认识首页当前画的是横轴、圆点还是其他视觉形式。

站点身份不是 settings 接口的删减版

旧思路容易走向 GET /settings 再由前端挑字段。问题在于“当前前端没用”不等于“适合公开”,而且管理设置的命名、默认值和迁移节奏都不应该成为首页契约。

site-identity 虽然在内部读取设置集合,对外只构造白名单 DTO。它会做几件不适合散落在组件里的归一化:

  • 站名为空或仍是脚手架默认值时,回落到 Violet;

  • 站点 URL 只接受没有 userinfo、query 和 fragment 的 HTTP(S) URL;

  • 头像和资源地址只接受根相对地址或 HTTP(S),拒绝反斜线和协议相对写法;

  • owner 未显式配置时,按 GitHub 用户名、站点 URL、站名依次回落;

  • 社交链接只有通过对应格式校验的项目才会出现;

  • 订阅渠道目前只返回真实存在的 RSS,不为了界面完整虚构邮件订阅。

接口使用 ETag,并设置 public, max-age=60, stale-while-revalidate=300。发布物流的变化更频繁,缓存窗口是 public, max-age=30, stale-while-revalidate=120。两个资源都可以被共享缓存;包含当前设备状态的印记接口则必须是 private, no-cache,并带 Vary: Cookie

“印记”不是另一个浏览量计数器

产品文案刻意叫“留下印记”,因为它代表一次主动动作。页面被打开不增加计数,机器人抓取 RSS 不增加计数,同一设备重复点击也不增加计数。这个数字不能解释成 UV,更不能反推独立自然人。

首次提交时,服务端用 crypto/rand 生成 16 字节随机令牌,通过 Base64URL 放进一年有效的 HttpOnly Cookie。数据库不保存 Cookie 原文,只保存模块专属密钥计算出的 HMAC-SHA256 摘要:

sql

主键和 INSERT ... ON CONFLICT DO NOTHING 共同承担幂等性。两个同令牌请求同时到达时,不需要先 SELECT 再决定是否插入;那种先查后写会留下竞态窗口。数据库唯一约束才是最终裁判。

匿名写接口仍然经过 CSRF。原因不是用户登录,而是跨站页面不应该替访客静默修改共享计数并植入设备 Cookie。前端发现没有 CSRF token 时先走现有 token 流程,再提交印记。接口还按 IP 做每分钟 10 次限流;IP 不以明文进入 Redis,限流键使用另一个用途字符串派生出的 HMAC 子密钥。设备摘要和 IP 限流即使共享根密钥,也不会共享同一摘要空间。

下面这个最小程序用内存 map 模拟唯一主键。它不是 PostgreSQL 并发测试,但能直接说明为什么“摘要作为唯一键”可以把 100 个同令牌请求收敛成一条记录。

go

实际输出:

text

“不存原始令牌”不等于完全不可关联。稳定 HMAC 摘要仍是一个持久的假名标识,只是它不向数据库暴露可直接重放的 Cookie,也不保存 IP。这是当前功能的隐私边界,不应写成更强的承诺。

首屏和延迟资源走两条链

后端资源划清后,前端不再用一个巨大的 Promise.all 等所有板块。

路由 loader 只并行预取站点身份与最近五条发布物,并用 Promise.allSettled 区分失败:站点身份是页面级依赖,读取失败会让路由进入错误态;最近发布物失败只记录一个 sectional flag,由对应板块展示错误和重试。pendingMs=150 避免毫秒级响应也强行闪一次骨架,真正进入 pending 后至少保持 200ms,减少一闪而过的布局切换。

其余资源在浏览器接管后按需启动:

资源

启动时机

失败影响

站点身份

路由 loader

页面级错误

最近五条发布物

与身份并行

最近更新板块错误,可重试

十二个月完整物流

客户端,且足迹功能开启

足迹板块错误,可重试

推文

客户端

推文板块独立处理

印记状态

客户端读取 Cookie 后

页尾交互独立处理

印记必须留在客户端链路,因为服务端渲染阶段不该把一个访问者的 Cookie 状态误混进公共缓存。十二个月窗口可能翻多页,也没有理由挡住 Hero 和最近更新。所谓“分层加载”不是把所有请求套上 lazy,而是先判断哪个事实决定首屏结构,哪个事实只影响一个可恢复板块。

TanStack Query 的缓存时间与接口缓存对应:身份 60 秒,发布物流 30 秒;印记状态设为立即过期,因为 impressed 与当前 Cookie 强相关。提交成功后,mutation 直接更新印记 query cache,按钮和计数不必再等一次 GET。

RSS 成了读模型的第二个消费者

统一发布物流如果只能服务一个首页组件,它仍然偏浅。/feed.xml 直接并行读取站点身份和最近 20 条发布物:身份提供 channel 标题、站点地址和简介;物流根据 kind 生成文章、笔记、图集链接。XML 中的标题与 URL 都经过转义,最近一条发布时间成为 lastBuildDate

RSS 设置 application/rss+xml; charset=utf-8,缓存五分钟并允许一小时 stale;任一上游失败时返回 503,而不是输出结构完整但内容空白的 feed。这样新增公开内容来源时,需要扩展的是发布物契约和一种路径映射,不再为首页与 RSS 分别维护一次跨表合并。

哪些结论已经被实际验证

以下结果来自这次实现的本地运行与浏览器验收,不是从代码形状推测出来的:

  1. make check-publications 在本地开发数据库输出 {"missing":[],"orphaned":[],"drifted":[]}

  2. 本地 API 实测:site-identity 返回 731 字节,publications?limit=5 返回 1574 字节,二者均为 200;进程已热身后的并行重复请求墙钟时间为 3.3ms。单独第一轮约 15ms。它只是本机样本,不是生产 SLA。

  3. site-impressions 返回 39 字节,响应头为 private, no-cache;浏览器点击后计数从 0 变为 1,刷新后仍显示当前设备已经留下印记。

  4. /feed.xml 返回 200,Content-Type 为 application/rss+xml; charset=utf-8,正文以合法 RSS 2.0 声明开始。

  5. 390×844 的移动端浏览器验收没有横向溢出;Hero、最近更新、足迹、印记和 RSS 入口均可到达。

  6. 常规后端测试和 lint 通过;前端 lint、typecheck 通过,完整前端测试记录为 158 个文件、980 个通过、1 个跳过。

  7. 上面两个 Go 示例均使用本机 Go 1.26.5 实际执行,输出已原样附在代码块后。

仓库还包含 PostgreSQL 集成测试,覆盖迁移幂等回填、复合排序与游标、索引执行计划、三类来源同步,以及投影写失败时的事务回滚。不过常规 make api-test 没有设置 BLOG_TEST_PG_DSN,这些用例在那次完整测试里按约定跳过。文章不会把“测试存在”冒充成“本次已连接独立测试库执行”。

当前实现仍有四个边界

下面不是已复现故障,而是从当前实现可以直接推导出的运维或一致性限制。

1. 根密钥轮换会改变既有身份

发布物游标依赖签名密钥。轮换后,尚未翻完页的旧游标会立即失效,这是安全但需要接受的行为。

印记更麻烦:数据库主键是 HMAC(key, token)。若只替换根密钥、不迁移摘要,浏览器继续携带原 Cookie 时会得到一个新的摘要,下一次点击可能被当成新设备。因为数据库没有令牌原文,也不可能离线重算旧行。正式做密钥轮换前,需要双密钥读取期、版本化摘要,或明确接受计数重置;当前代码尚未提供该流程。

2. 跨页读取不是冻结快照

单次仓储查询有稳定全序,但每一页是独立 HTTP 请求。翻页期间新增的最新内容通常只会出现在游标之前,不影响后续页;如果管理员修改已有条目的 published_at,记录可能跨越游标边界,造成一次会话里的遗漏或重复。要获得严格快照,需要在游标里加入快照版本,或从不可变发布事件读取。首页足迹目前接受这种低频管理变更下的弱点。

3. 一致性检查器只报警,不修复

只报告是有意选择,但也意味着部署手册还需要明确:发现 missing、orphaned、drifted 后,应该重放哪个来源动作,何时允许重新回填,修复前是否暂停发布。没有这套操作流程,检查器只能告诉我们数据坏了,不能缩短恢复时间。

4. 印记总数仍是实时 COUNT(*)

当前表很窄,主键也是固定长度摘要,在现有规模下直接计数最简单。数据增长到足以让每次 GET 的 COUNT(*) 成为成本后,应基于实际查询计划决定是否加计数器或近似统计;现在提前维护一个可能漂移的汇总值,反而会把一次幂等插入变成新的双写问题。

回到最初的问题

首页资源改造后,请求总数不一定永远最少:足迹仍可能翻页,推文和印记也保留独立请求。变化在于每个请求现在有清楚的所有者和失败边界。

“最近五条”由数据库全序回答;“十二个月足迹”必须续取完整窗口;图集只暴露已发布版本;来源与投影一起提交;印记只记录主动且去重的设备动作;首屏不再等待所有非关键资源。前端拿到的是可直接展示的事实,不需要再从几个领域接口里猜一个答案。

这比做一个返回整页 JSON 的 /home 接口多了一些建模工作,却让首页、RSS、缓存和后续消费者共享同一组窄契约。对这类聚合页面,真正值得优化的通常不是请求数字,而是把“谁负责正确”说清楚。

源码与延伸阅读

  • 需求与边界:docs/prd/0024-首页资源接口.md

  • 投影迁移:api/migrations/111_create_publication_entries.up.sql

  • 发布物流应用服务:api/internal/application/publication/service.go

  • 投影仓储与一致性检查:api/internal/infrastructure/persistence/gorm/publication_repo.gopublication_check.go

  • 首页加载链:web/src/routes/index.tsxweb/src/widgets/HomeExperience/api/

  • RSS:web/src/routes/feed[.]xml.ts

  • 实现 PR:VOD-Studio/violet#316

  • PostgreSQL 文档:Indexes and ORDER BYTransaction Isolation

  • HMAC 定义:RFC 2104

  • Cookie 属性:MDN Set-Cookie

评论 (0)

登录后查看评论并参与完整讨论