Violet 首页资源接口的实现:统一发布物流与分层加载
首页看上去只是几个板块:站点介绍、最近更新、过去一年的创作轨迹、订阅入口,再加一个可以点下去的“印记”。如果只看组件,很容易把它理解成一次普通的接口整理。
真正动手后,我发现问题根本不在组件数量,而在首页一直没有自己的读取模型。文章、笔记、图集各自能分页,站点设置也能读取,归档接口还能按年份统计;这些接口单独看都没有错,但把它们同时交给首页,仍然回答不了几个很基础的问题:
“最近五条”究竟是三个来源各取一点再拼,还是全站严格有序的五条?
过去十二个自然月的数据如果超过一页,前端拿到的是完整窗口,还是看起来完整的一页?
图集有工作版本和已发布版本,首页应该跟着哪个标题变化?
一个匿名访客连续点击、刷新,甚至并发发出请求时,印记数会不会重复增加?
首屏中某个非关键板块失败,是否应该拖垮整个页面?
这次改造最终没有做一个“万能首页接口”。我把首页需要的公开数据收敛成三种资源:站点身份、发布物流、站点印记;再把首屏资源和延迟资源拆成两条加载链。后端负责跨内容类型的顺序与一致性,前端只负责按页面节奏消费,不再猜测全局事实。
本文沿着实际实现展开。涉及的主要入口是 publication_entries 投影、三类来源的事务写入、HMAC 复合游标、十二个月续取、匿名设备印记和 /feed.xml。文末会把运行验证、只由源码确认的行为、尚未解决的边界分开写,避免把“有测试文件”说成“已经在真实 PostgreSQL 上跑过”。
首页不是七个列表的拼接页
旧首页最多会为一次展示发出九个 HTTP 请求。请求多只是表象,更难处理的是每个接口都在暴露自己的领域形状。
旧来源 | 它原本回答的问题 | 首页真正需要的事实 |
|---|---|---|
文章列表 | 文章如何分页 | 它在全部公开内容中的位置 |
笔记列表 | 笔记如何分页 | 它在全部公开内容中的位置 |
图集列表 | 图集如何分页 | 当前已发布版本的标题与入口 |
系列列表 | 系列容器有哪些 | 不属于发布物流 |
推文列表 | 短动态有哪些 | 独立板块,允许延迟加载 |
公告列表 | 当前公告是什么 | 独立业务,不参与“最近创作” |
归档年份与年份详情 | 某年有哪些文章 | 连续十二个月的完整发布分布 |
站点设置 | 管理端有哪些配置 | 可公开、稳定、已归一化的站点身份 |
前端把多个有限样本合并,只能得到“这些样本里的最新”,不能得到由服务端承诺的全局顺序。归档接口也有类似问题:它按年份和文章域设计,首页却想画一条跨文章、笔记、图集的连续时间线。即使九个响应都返回 200,页面仍可能展示一个语义上不完整的答案。
因此第一步不是减少请求,而是重新划边界。
资源 | 对外职责 | 明确不负责 |
|---|---|---|
| 站名、站主、简介、头像、社交链接、订阅渠道、首页功能开关 | 管理端设置全集、密钥、OAuth 凭据 |
| 文章、笔记、图集的统一公开顺序与时间窗口 | 系列容器、推文、公告、正文详情 |
| 主动留下印记的去重设备数,以及当前设备是否已登记 | 浏览量、可信 UV、用户画像 |
这个边界很重要。系列是内容组织容器,不是一条独立发布;推文和公告有自己的展示节奏,也不该为了“统一”被硬塞进创作物流。统一读模型的目标不是把所有表压成一张表,而是给一个明确页面提供它真正需要的稳定事实。
用普通投影表承接全局发布顺序
跨三张来源表做 UNION ALL 并不难。难的是每次首页读取都要重复处理状态、软删除、图集版本和笔记标题派生,之后再排序、分页。查询能写出来,不代表它适合成为长期公开契约。
最终实现增加了一张很窄的 publication_entries:
它不是物化视图,也不是另一套内容主表。来源实体仍然拥有正文、状态机和业务规则;投影只保存首页、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 实例里,最短也最强的保证就是同一事务。
PostPublicationUnitOfWork 和 NotePublicationUnitOfWork 会在一个 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 再把同类型同时间的记录排成唯一顺序。
从上一页最后一条继续时,查询条件必须和三个排序方向完全对应:
这里 kind 用 >,source_id 却用 <,不是笔误。前者升序,后者降序;复合游标本质上是在手写这组三元组的字典序“下一段”。仓储集成测试会故意插入同一发布时间的 article、gallery、note,锁住这个混合方向。
应用服务每次请求 limit + 1 条。如果多出来一条,截掉它并返回 has_more=true;下一游标取当前页最后一条,而不是那条探测记录。默认一页 20 条,最大 100 条。
游标中包含版本、发布时间、kind、source ID。它用 Base64URL 编码,但 Base64 不是防篡改机制,所以载荷后面还带 HMAC-SHA256 签名。服务端先做常量时间签名比较,再校验版本、kind、RFC3339Nano 时间和 UUID。客户端可以看见游标内容,却不能把时间改早来绕过查询边界。
下面是从该机制缩减出来的可执行程序。它不是生产文件的复制品,只保留“签名载荷”和“篡改后拒绝”两件事。
在 Go 1.26.5 下的实际输出:
签名游标解决的是完整性,不是快照隔离。两次翻页请求之间如果已有条目的 published_at 被修改,它可能跨过游标边界;这一点会在文末单独列为剩余限制。
十二个月窗口必须续取到结束
首页“足迹”展示最近十二个自然月,不是最近 365 天。前端以 UTC 计算当前月月初,再向前推十一个月,得到 [from, to) 半开区间。例如参考时间位于 2026 年 9 月时,窗口是:
服务端的 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 摘要:
主键和 INSERT ... ON CONFLICT DO NOTHING 共同承担幂等性。两个同令牌请求同时到达时,不需要先 SELECT 再决定是否插入;那种先查后写会留下竞态窗口。数据库唯一约束才是最终裁判。
匿名写接口仍然经过 CSRF。原因不是用户登录,而是跨站页面不应该替访客静默修改共享计数并植入设备 Cookie。前端发现没有 CSRF token 时先走现有 token 流程,再提交印记。接口还按 IP 做每分钟 10 次限流;IP 不以明文进入 Redis,限流键使用另一个用途字符串派生出的 HMAC 子密钥。设备摘要和 IP 限流即使共享根密钥,也不会共享同一摘要空间。
下面这个最小程序用内存 map 模拟唯一主键。它不是 PostgreSQL 并发测试,但能直接说明为什么“摘要作为唯一键”可以把 100 个同令牌请求收敛成一条记录。
实际输出:
“不存原始令牌”不等于完全不可关联。稳定 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 分别维护一次跨表合并。
哪些结论已经被实际验证
以下结果来自这次实现的本地运行与浏览器验收,不是从代码形状推测出来的:
make check-publications在本地开发数据库输出{"missing":[],"orphaned":[],"drifted":[]}。本地 API 实测:
site-identity返回 731 字节,publications?limit=5返回 1574 字节,二者均为 200;进程已热身后的并行重复请求墙钟时间为 3.3ms。单独第一轮约 15ms。它只是本机样本,不是生产 SLA。site-impressions返回 39 字节,响应头为private, no-cache;浏览器点击后计数从 0 变为 1,刷新后仍显示当前设备已经留下印记。/feed.xml返回 200,Content-Type 为application/rss+xml; charset=utf-8,正文以合法 RSS 2.0 声明开始。390×844 的移动端浏览器验收没有横向溢出;Hero、最近更新、足迹、印记和 RSS 入口均可到达。
常规后端测试和 lint 通过;前端 lint、typecheck 通过,完整前端测试记录为 158 个文件、980 个通过、1 个跳过。
上面两个 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.go、publication_check.go首页加载链:
web/src/routes/index.tsx、web/src/widgets/HomeExperience/api/RSS:
web/src/routes/feed[.]xml.ts实现 PR:VOD-Studio/violet#316
PostgreSQL 文档:Indexes and ORDER BY、Transaction Isolation
HMAC 定义:RFC 2104
Cookie 属性:MDN Set-Cookie
评论 (0)
登录后查看评论并参与完整讨论