Halo 文章“上一篇/下一篇”的分类域内导航:cursorByCategory与?scope=category设计与实现解析
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
本文围绕 Halo 开源建站系统中“文章页上一篇/下一篇导航”按分类域收敛的能力展开,介绍一项回应 issue halo-dev/halo#5634 的纯增量特性:主题侧新增PostFinder.cursorByCategory(...)Finder API、REST 侧扩展?scope=category查询参数,以及“以主分类精确匹配、不级联子分类”的完整行为语义。读完本文,你将掌握在主题模板与 HTTP API 两个层面启用分类域内导航的方法,并理解其底层查询构造、空值边界与既有行为零破坏的设计取舍。
该变更的完整记录位于 openspec/changes/archive/2026-05-19-issue-5634-category-post-navigation/proposal.md,配套的设计决策、验收规格与任务清单见同目录下的 design.md、specs/category-post-navigation/spec.md 与 tasks.md。
一、背景:全局导航在分类站点的体验缺口
在 Halo 中,文章详情页的“上一篇 / 下一篇”(previous/next)导航长期由postFinder.cursor(...)提供。它的语义是:以当前文章的发布时间为锚点,在全站所有已发布文章范围内查找相邻的前后两篇(见 PostFinderImpl.java 中cursor的实现:以spec.publishTime做lessThan/greaterThan条件并排序分页取一)。
这种“全站时间序相邻”的模式对于纯博客尚可接受,但对以分类组织内容的站点(典型如知识库、产品文档、系列教程)并不友好——读者在阅读某分类的一篇文章后,点击“下一篇”却可能被带到完全无关主题的“下一篇新文章”。issue halo-dev/halo#5634 正是要求提供一种将导航限定在同一分类内的选项。
本次变更的核心目标,归纳为如下四点(proposal 的 “What Changes” 一节):
- 在主题 Finder 接口
PostFinder中新增cursorByCategory(String currentName),返回限定在该文章主分类内的上一篇/下一篇(“主分类”定义为spec.categories的第一个元素); - 扩展现有 REST 端点
GET /posts/{name}/navigation,通过新增?scope=category查询参数暴露分类域内导航能力,供 headless/API 消费方使用; - 若文章没有任何分类,新 API 返回空的
NavigationPostVo; - 既有
cursor()行为完全不变——本特性是纯增量(additive)的。
二、整体能力与影响范围
新增能力声明
Proposal 以 “Capabilities” 清单形式声明该变更新增了一项能力category-post-navigation:
category-post-navigation:Theme Finder 与 REST API 中用于在文章主分类内进行上一篇/下一篇导航的能力。
同时明确:没有修改任何既有能力——cursor()行为与PostFinder的既有契约保持不变。
影响的仓库文件
proposal 的 “Impact” 一节给出了精确的改动面:
- PostFinder.java——接口新增方法;
- PostFinderImpl.java——核心实现;
- PostQueryEndpoint.java——REST 端点扩展;
application/src/test/...——针对新行为的单元测试。
从当前仓库源码看,这一影响面得到严格贯彻:全仓仅有上述三处源码文件涉及cursorByCategory,且均位于application模块,未触碰Post/Category数据模型。
三、主题侧 Finder API:PostFinder.cursorByCategory
3.1 接口形态
在接口 PostFinder.java 中,新旧两个方法并排声明,二者都返回响应式类型Mono<NavigationPostVo>:
Mono<NavigationPostVo> cursor(String current); Mono<NavigationPostVo> cursorByCategory(String current);NavigationPostVo承载previous与next两个字段,分别由ListedPostVo::from转换(见下文实现中对NavigationPostVo.builder()的调用)。整个 Halo 的 Finder 体系建立在 Project Reactor(Mono/Flux)之上,因此即便接口方法签名只有一行,其背后的查询链路也是完全响应式的。
在 Halo 中,Finder 是为主题模板(Thymeleaf)准备的查询门面。
PostFinderImpl通过@Finder("postFinder")注解(见 PostFinderImpl.java)以postFinder为 bean 名暴露给模板渲染层。
3.2 “主分类”的确定:spec.categories首元素
Post扩展(Extension)将一篇文章所属的分类以字符串列表List<String>存放在spec.categories中,数据模型上并不存在显式的“主分类”字段。design.md 记录的团队共识是:
约定将
spec.categories中的第一个元素视为文章的主分类(primary category)。
这是纯约定而非模型变更——design.md 的 Non-Goals 中明确排除了对Categoryspec 与Postspec 数据模型的修改。理解这一点很重要:主分类不是“某个分类被标记为 primary”,而是依赖列表顺序的约定,其稳定性风险在 design.md 的 “Risks / Trade-offs” 表中被明确接受并在 Finder API 文档中说明。
3.3 实现流程拆解
在 PostFinderImpl.java 中,cursorByCategory的实现如下(节选核心逻辑):
@Override public Mono<NavigationPostVo> cursorByCategory(String currentName) { return client.fetch(Post.class, currentName) .filter(p -> Post.isPublished(p.getMetadata())) .filter(p -> p.getSpec() != null && p.getSpec().getPublishTime() != null) .flatMap(currentPost -> { var categories = currentPost.getSpec().getCategories(); if (categories == null || categories.isEmpty()) { return Mono.fromSupplier(NavigationPostVo::empty); } var primaryCategory = categories.get(0); var findPreviousPost = findPreviousPostByCategory(currentPost, primaryCategory) .map(Optional::of) .defaultIfEmpty(Optional.empty()); var findNextPost = findNextPostByCategory(currentPost, primaryCategory) .map(Optional::of) .defaultIfEmpty(Optional.empty()); return Mono.zip( findPreviousPost, findNextPost, (previous, next) -> NavigationPostVo.builder() .previous(previous.map(ListedPostVo::from).orElse(null)) .next(next.map(ListedPostVo::from).orElse(null)) .build()); }) .switchIfEmpty(Mono.fromSupplier(NavigationPostVo::empty)); }整个过程与全局版cursor()的结构高度对称,可归纳为四步:
- 取文章并做公开性过滤:
client.fetch(Post.class, currentName)取回文章后,先过滤掉未发布(Post.isPublished)以及无spec.publishTime的文章——导航的排序基准是发布时间,缺少它无法定位“前后”; - 空分类兜底:若
spec.categories为null或空列表,直接返回NavigationPostVo.empty(); - 锁定主分类:取
categories.get(0)作为本次导航的筛选条件; - 并发查询前后篇:用
Mono.zip同时并发执行上一篇与下一篇两条查询,任一缺失用Optional.empty()补位,最终组装出previous/next可能为null的NavigationPostVo。
switchIfEmpty覆盖了“文章不存在或未发布”的路径——此时fetch/filter链路为空,同样回落为NavigationPostVo.empty()。这与规格 spec.md 中的三类空值场景一一对应:文章有分类、文章无分类、文章不存在或未发布。
四、核心语义:精确匹配主分类,不做子分类级联
4.1 查询条件构造
category 版前后篇查询的独特之处,在于筛选条件只叠加两个(见 PostFinderImpl.java):
// 上一篇:publishTime 小于当前文章,且分类精确等于主分类 ListOptions.builder(listOptions) .andQuery(Queries.lessThan("spec.publishTime", publishTime)) .andQuery(Queries.equal("spec.categories", categoryName)) .build(); // 下一篇:publishTime 大于当前文章,且分类精确等于主分类 ListOptions.builder(listOptions) .andQuery(Queries.greaterThan("spec.publishTime", publishTime)) .andQuery(Queries.equal("spec.categories", categoryName)) .build();排序上,上一篇按spec.publishTime降序、下一篇按升序,均以metadata.name作同时间戳时的确定性次级排序,取ofSize(1)只返回紧邻的一篇。
关键点在于Queries.equal("spec.categories", categoryName):这是字段值的精确相等匹配。design.md 明确指出,这有别于现有分类列表查询listByCategory(...)的级联行为——后者会先通过categoryService.listChildren(categoryName)拿到当前分类及其全部子分类的名称集合,再用in("spec.categories", categoryNames)做包含匹配(见 PostFinderImpl.java)。分类域内导航刻意不采用这套级联基础设施。
4.2 设计决策与规格化场景
design.md 的 “Decision 1: Exact-match primary category, no cascade” 解释了取舍依据:用户明确选择了“精确匹配”方案,它比listByCategory的级联语义更简单、对主题作者可预期。对应地,规格 spec.md 给出了可验收的对照场景:
场景:父分类下的文章当前文章的主分类是
"java",另一篇文章的spec.categories为["spring-boot"]("java"的子分类)——结果:位于"spring-boot"的文章不得出现在主分类为"java"的文章导航中。
也就是说,一篇只挂在"Java"子分类"Spring Boot"下的文章,不会“向上冒泡”进父分类"Java"的相邻导航;导航双方必须直接同挂一个主分类。
4.3 依赖顺序的风险与缓解
由于主分类依赖spec.categories的顺序,design.md 的 “Risks / Trade-offs” 表对此有坦诚的评估:
| 风险 | 缓解措施 |
|---|---|
| 分类列表顺序不稳定(首元素可能变化) | 团队共识接受此行为,并在 Finder API 文档中予以说明 |
| 挂多分类的文章可能因首分类不同而导航结果不同 | 同上——团队共识接受;由主题作者对用户做引导 |
在PostFinder接口上新增方法是次要的 API 变更 | 所有实现类必须同步更新;当前核心仅存在PostFinderImpl一个实现 |
五、REST API 扩展:scope=category查询参数
面向 headless / API 消费方,PostQueryEndpoint.java 在既有导航端点上做了最小化扩展。端点路由为posts/{name}/navigation(聚合完整路径即GET /apis/api.content.halo.run/v1alpha1/posts/{name}/navigation),路由构建时即为scope参数生成 API 文档描述:
“Scope of navigation. Use 'category' to limit navigation to the post's primary category. Defaults to global scope.”
实际分发的核心逻辑只有一行三态判断:
var scope = request.queryParam("scope").orElse(""); var navigationMono = "category".equals(scope) ? postFinder.cursorByCategory(name) : postFinder.cursor(name); return navigationMono.flatMap(result -> ServerResponse.ok().bodyValue(result));对应规格 spec.md 中的两条 REST 需求场景:
- 请求
?scope=category:返回限定在主分类内的上一篇/下一篇; - 省略
scope参数(或传入其他任意值):完整保留既有全局导航行为。
"category".equals(scope)的写法意味着任何非category值(含空串)一律回退全局导航,这与 design.md 的 “Decision 3” 一致——在同一资源(文章导航)上仅增加一个范围维度,避免新增独立路径扩大 REST 表面积;参数缺省时行为与旧版本逐字节一致,对存量 API 客户端零影响。
六、边界细节:隐藏文章、置顶与确定性
6.1hideFromList文章的排除
规格要求分类域内导航同样遵守既有hideFromList过滤:紧邻文章若status.hideFromList = true则跳过,返回下一个合格的可见文章。这一约束通过查询的基线 ListOptions 源自公开查询谓词实现——findPreviousPostByCategory/findNextPostByCategory都以postPredicateResolver.getListOptions()为起点(ReactiveQueryPostPredicateResolver),该基线已封装公开站点对文章的可见性判定;全局版cursor()路径还会在此基础上再显式叠加notHiddenPostQuery()(即notEqual("status.hideFromList", BooleanUtils.TRUE))。因此无论全局还是分类域,隐藏文章都不会作为“上一篇/下一篇”被返回。
6.2 同一发布时间下的确定性
由于存在同分钟批量发布的多篇文章,前后篇查询在排序上都以metadata.name作为次级排序键,保证主分类内的相邻关系在重复请求下是确定且稳定的。
6.3 空NavigationPostVo的汇总
综合 proposal、design 与实现,以下三种输入都会得到空的NavigationPostVo(previous与next均为空):
- 文章存在且已发布,但
spec.categories为空或null; - 文章不存在;
- 文章存在但未发布 / 无发布时间。
七、测试覆盖与验证结论
7.1 单元测试
变更按 tasks.md 的规划在两类测试中落地:
- PostFinderImplTest.java:覆盖
cursorByCategory的分类内导航行为,包括“有分类→按主分类域返回前后篇”“无分类→空结果”“隐藏相邻文章→被跳过并返回下一个合格文章”等分支; - PostQueryEndpointTest.java:模拟对
GET /posts/{name}/navigation?scope=category的请求,断言端点按预期分发到postFinder.cursorByCategory(其中对scope=category路径的 mock 与verify明确验证了路由分支)。
7.2 验证清单
tasks.md 的收尾步骤还包含工程层面的验证动作,可作为复现指引:
- 对新增代码执行
./gradlew spotlessApply统一格式化; - 运行
./gradlew test验证全部测试通过; - 运行
./gradlew build完成整体构建; - 确认既有
cursor()行为无破坏(纯增量承诺); - 复核 SpringDoc 生成的 OpenAPI 文档已收录新查询参数(本项目 API 文档汇总可见于 api-docs/openapi/v3_0 目录下的各 JSON 文件)。
八、主题开发者接入指引
8.1 Thymeleaf 模板侧
postFinderbean 已注册到主题渲染上下文。在希望“同分类内翻页”的文章模板中,将原先的全局调用替换/补充为分类域版本即可,例如:
<!-- 分类域内相邻文章 --> <th:block th:with="nav=${postFinder.cursorByCategory(post.metadata.name)}"> <a th:if="${nav.previous != null}" th:href="@{/archives/${nav.previous.metadata.name}}"> 上一篇 </a> <a th:if="${nav.next != null}" th:href="@{/archives/${nav.next.metadata.name}}"> 下一篇 </a> </th:block>NavigationPostVo.previous/.next为ListedPostVo类型(不存在时为null),因此模板中必须判空后再取字段——尤其对“未挂分类的文章”,该接口固定返回空导航,模板需优雅降级(例如隐藏导航区块或回退到postFinder.cursor(...)的全局版本)。若需要“既有全站翻页、特定页面用分类内翻页”的混合体验,可同时保留postFinder.cursor(...)与postFinder.cursorByCategory(...)两套调用,二者互不干扰。
8.2 HTTP API 侧
对 headless 架构的站点或自建前端,直接给既有导航端点追加查询参数即可:
GET /apis/api.content.halo.run/v1alpha1/posts/{name}/navigation?scope=category返回 JSON 中previous/next将被限定在当前文章主分类内;不传scope则行为与旧版本完全一致。两种调用方式共享同一份语义:主分类精确匹配、无子分类级联、隐藏文章排除、无分类返回空导航。
九、结语
Halo 的这次变更把“相邻文章”从单一的全局时间序,扩展为“全局 + 主分类域”双模并存的形态。它没有改动cursor()一行逻辑、没有改动Post/Category数据模型、没有引入控制台配置项,而是以“接口新增 + 查询参数扩展”两个纯增量切口,把分类站点的阅读连续性交给了主题作者与 API 调用方按需选择。其中“spec.categories首元素即主分类”的约定,以及“精确匹配、拒绝级联”的克制语义,正是这一能力在简洁性与可预期性之间的权衡结果。若需追溯完整的决策上下文与验收规格,可继续阅读 proposal.md、design.md 与 spec.md。
【免费下载链接】haloHalo 是一款强大易用的开源建站工具,从个人博客、知识库,到企业官网、在线商城,Halo 都能助您轻松实现,一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考