
Halo 文章“上一篇/下一篇”的分类域内导航cursorByCategory与?scopecategory设计与实现解析【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo本文围绕 Halo 开源建站系统中“文章页上一篇/下一篇导航”按分类域收敛的能力展开介绍一项回应 issue halo-dev/halo#5634 的纯增量特性主题侧新增PostFinder.cursorByCategory(...)Finder API、REST 侧扩展?scopecategory查询参数以及“以主分类精确匹配、不级联子分类”的完整行为语义。读完本文你将掌握在主题模板与 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通过新增?scopecategory查询参数暴露分类域内导航能力供 headless/API 消费方使用若文章没有任何分类新 API 返回空的NavigationPostVo既有cursor()行为完全不变——本特性是纯增量additive的。二、整体能力与影响范围新增能力声明Proposal 以 “Capabilities” 清单形式声明该变更新增了一项能力category-post-navigationcategory-post-navigationTheme Finder 与 REST API 中用于在文章主分类内进行上一篇/下一篇导航的能力。同时明确没有修改任何既有能力——cursor()行为与PostFinder的既有契约保持不变。影响的仓库文件proposal 的 “Impact” 一节给出了精确的改动面PostFinder.java——接口新增方法PostFinderImpl.java——核心实现PostQueryEndpoint.java——REST 端点扩展application/src/test/...——针对新行为的单元测试。从当前仓库源码看这一影响面得到严格贯彻全仓仅有上述三处源码文件涉及cursorByCategory且均位于application模块未触碰Post/Category数据模型。三、主题侧 Finder APIPostFinder.cursorByCategory3.1 接口形态在接口 PostFinder.java 中新旧两个方法并排声明二者都返回响应式类型MonoNavigationPostVoMonoNavigationPostVo cursor(String current); MonoNavigationPostVo cursorByCategory(String current);NavigationPostVo承载previous与next两个字段分别由ListedPostVo::from转换见下文实现中对NavigationPostVo.builder()的调用。整个 Halo 的 Finder 体系建立在 Project ReactorMono/Flux之上因此即便接口方法签名只有一行其背后的查询链路也是完全响应式的。在 Halo 中Finder 是为主题模板Thymeleaf准备的查询门面。PostFinderImpl通过Finder(postFinder)注解见 PostFinderImpl.java以postFinder为 bean 名暴露给模板渲染层。3.2 “主分类”的确定spec.categories首元素Post扩展Extension将一篇文章所属的分类以字符串列表ListString存放在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 MonoNavigationPostVo 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 扩展scopecategory查询参数面向 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 posts 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 需求场景请求?scopecategory返回限定在主分类内的上一篇/下一篇省略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 与实现以下三种输入都会得到空的NavigationPostVoprevious与next均为空文章存在且已发布但spec.categories为空或null文章不存在文章存在但未发布 / 无发布时间。七、测试覆盖与验证结论7.1 单元测试变更按 tasks.md 的规划在两类测试中落地PostFinderImplTest.java覆盖cursorByCategory的分类内导航行为包括“有分类→按主分类域返回前后篇”“无分类→空结果”“隐藏相邻文章→被跳过并返回下一个合格文章”等分支PostQueryEndpointTest.java模拟对GET /posts/{name}/navigation?scopecategory的请求断言端点按预期分发到postFinder.cursorByCategory其中对scopecategory路径的 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:withnav${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:blockNavigationPostVo.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?scopecategory返回 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),仅供参考