
Halo 分类内文章导航cursorByCategory 主题 API 与 scopecategory REST 参数的实现全解【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo导读本文围绕 HaloGitHub_Trending/ha/halo中由 Issue halo-dev/halo#5634 驱动的分类范围文章导航能力展开系统讲解其从 Finder 接口、核心实现、REST 端点扩展、行为契约到测试验证的完整链路。读完本文你将掌握如何为主题模板调用postFinder.cursorByCategory(...)获得同分类内上一篇/下一篇结果、如何通过GET /posts/{name}/navigation?scopecategory消费该能力以及主分类定义、精确匹配语义、空结果与隐藏文章等边界行为的准确约定。1. 背景全局导航的局限与需求来源在 Halo 中主题系统会向 Thymeleaf 模板暴露一个名为postFinder的 bean对应实现类标有Finder(postFinder)见 PostFinderImpl.java。其中的cursor(String currentName)方法用于返回上一篇 / 下一篇文章其计算方式是按发布时间在全部公开文章中做全局排序后取出相邻两篇。这种全局语义在个人博客场景下没有问题但当站点以分类组织内容例如知识库、文档站时用户点击文章底部导航却会跳到毫不相关的其他分类体验割裂。设计文档将需求明确表述为在站点按分类组织内容的场景下如知识库、文档站用户期望导航停留在同一分类内而非跳到无关主题见 proposal.md。Issue #5634 正是请求提供分类范围category-scoped的导航能力。2. 核心设计决策四项关键约定围绕这一需求仓库内的 design.md 记录了团队的四项核心决策理解它们是理解后续代码的前提2.1 主分类的判定规则spec.categories首元素Post扩展Custom Resource中以ListString存储分类名字段为spec.categories并不存在显式的主分类字段。团队内部达成共识spec.categories的第一个元素即被视为该文章的主分类primary category。实现中正是通过categories.get(0)取得主分类名。⚠️ 随之而来的已知风险design.md 中明确记录分类列表顺序是不稳定的若首元素发生变化文章上一篇/下一篇的范围就会改变对多分类文章最终导航范围取决于哪个分类排在首位。这一取舍被团队接受并要求在 Finder API 文档中说明主题作者可通过引导用户维护分类顺序来规避歧义。2.2 精确匹配主分类不做子分类级联用户明确选择了精确匹配方案文章的导航范围仅限定在直接挂有该主分类名的文章集合内。例如某篇文章主分类为java那么另一篇挂在java的子分类spring-boot下的文章不会出现在该导航结果中不级联。这与既有的listByCategory的级联行为不同语义对主题作者而言更可预期。2.3 新增方法而非修改cursor()纯增量演进cursor()是稳定且被广泛使用的 API直接改其语义将对现有主题造成破坏。因此选择在PostFinder接口中新增cursorByCategory(String currentName)方法主题作者按需 opt-in既有cursor()行为完全不变。2.4 复用既有端点 查询参数而非新增路径分类导航不新增 REST 路径而是在既有端点GET /apis/api.content.halo.run/v1alpha1/posts/{name}/navigation上增加可选查询参数scopescopecategory走分类导航缺省或其他任意值保持全局行为。这样保持了 REST 面的最小化且端点本身仍代表同一资源文章导航。非目标Non-Goals不涉及 Console UI 改动或分类级开关不改动Category/Post的数据模型不修改既有cursor()行为分类导航不级联子分类。3. Finder API 层接口新增与实现细节3.1 接口签名PostFinder接口新增了与cursor并列的方法PostFinder.javaMonoNavigationPostVo cursor(String current); MonoNavigationPostVo cursorByCategory(String current);二者均接收当前文章名metadata.name返回MonoNavigationPostVo——该值对象持有previous/next两个ListedPostVo字段分别对应当前文章的上一篇与下一篇无相邻文章时对应字段为null。3.2 实现主流程解析核心实现位于 PostFinderImpl.java其执行链路可分四步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()一致先确保当前文章已发布Post.isPublished(metadata)且spec.publishTime非空。若文章不存在、未发布或无发布时间switchIfEmpty使整条链返回NavigationPostVo.empty()即上一篇、下一篇均为空。无分类短路读取spec.categories若为null或空集合直接返回NavigationPostVo.empty()——这是需求规格中无分类文章不产生分类导航约定的实现落点注意早期任务清单中曾有回退到cursor()的表述但最终按 spec.md 的约束实现为空结果。取主分类categories.get(0)作为主分类名。并发查找 组装将查找上一篇与查找下一篇两个Mono用Mono.zip合并任一缺失时以Optional.empty()兜底最终构建NavigationPostVo。3.3 相邻文章查询精确等值条件的构建分类版的前后文查询私有方法PostFinderImpl.java与全局版同构仅在条件上做了两处替换——用分类等值过滤代替全局可见过滤语义对比非常直观维度全局cursor()相邻查询分类cursorByCategory()相邻查询时间下界条件Queries.lessThan(spec.publishTime, publishTime)取前一篇Queries.greaterThan(...)取后一篇完全相同分类过滤无Queries.equal(spec.categories, categoryName)精确等值索引友好排序方向前一篇按publishTime降序取第 1 条后一篇按publishTime升序取第 1 条并列时以metadata.name做次序完全相同页大小ofSize(1)只取 1 条完全相同分类版前一篇查询的核心片段private MonoPost findPreviousPostByCategory(Post currentPost, String categoryName) { var publishTime currentPost.getSpec().getPublishTime(); return postPredicateResolver .getListOptions() .map(listOptions - ListOptions.builder(listOptions) .andQuery(Queries.lessThan(spec.publishTime, publishTime)) .andQuery(Queries.equal(spec.categories, categoryName)) .build()) .flatMap(listOptions - { var sort Sort.by(Sort.Order.desc(spec.publishTime), Sort.Order.desc(metadata.name)); return client.listBy(Post.class, listOptions, ofSize(1).withSort(sort)); }) .flatMap(listResult - Mono.justOrEmpty(listResult.getItems().stream().findFirst())); }值得注意的实现事实上一篇与下一篇的方向性由排序方向 时间比较算子共同决定查前一篇用desc排序取最大者查后一篇用asc排序取最小者两者都限制ofSize(1)避免把整类文章全部加载。公开可见性查询基于ReactiveQueryPostPredicateResolver#getListOptions()生成的公开列表选项起步保证只面向站点前台可见的文章集合需求规格同时要求排除status.hideFromList true的隐藏文章——即当前文章的紧邻位置若是隐藏文章应跳过它而返回下一个符合条件的可见文章这一行为在 PostFinderImplTest.java 中被显式覆盖场景见 spec.md。值得注意的是分类查询通过Queries.equal走的是精确字符串等值匹配天然实现不级联子分类的约定子分类文章不会因为名称层级关系如javavsspring-boot而被误收录。4. REST API 层scopecategory查询参数4.1 端点路由声明分类导航能力以复用既有路径、增加可选参数的方式暴露在 PostQueryEndpoint.java 中。端点GET posts/{name}/navigation新增了文档化的scope查询参数.GET( posts/{name}/navigation, this::getPostNavigationByName, builder - builder.operationId(queryPostNavigationByName) .description(Gets a post navigation by metadata.name.) .tag(tag) .parameter(parameterBuilder() .in(ParameterIn.PATH) .name(name) .description(Post metadata.name) .required(true)) .parameter(parameterBuilder() .in(ParameterIn.QUERY) .name(scope) .description(Scope of navigation. Use category to limit navigation to the posts primary category. Defaults to global scope.) .required(false)) .response(responseBuilder().implementation(NavigationPostVo.class))) .build();路由处理器PostQueryEndpoint.java的转发逻辑极其精简——通过一次String.equals决定调用哪一个 Finder 方法private MonoServerResponse getPostNavigationByName(ServerRequest request) { final var name request.pathVariable(name); 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 中的验收场景scope取值行为缺省保持原有全局导航cursorcategory返回限定在当前文章主分类内的上一篇 / 下一篇其他任意字符串等价于缺省回落全局导航4.2 请求 / 响应形态完整的公开端点地址来自 spec 定义为GET /apis/api.content.halo.run/v1alpha1/posts/{name}/navigation?scopecategory其中{name}为文章的metadata.name。例如请求某篇主分类为java的文章导航GET /apis/api.content.halo.run/v1alpha1/posts/java-guide/navigation?scopecategory响应为NavigationPostVo的 JSON 形态示意字段结构为previous/next两个可空的ListedPostVo{ previous: { metadata: { name: java-basics-2 }, spec: { title: Java 基础二, categories: [java, tutorial] } }, next: { metadata: { name: java-advanced-1 }, spec: { title: Java 进阶一, categories: [java] } } }当无上一篇或下一篇时对应字段为null当当前文章没有分类、不存在或未发布时返回的空NavigationPostVo两个字段均为null。4.3 参数如何进入 OpenAPI 文档该端点使用 Halo 自研的 RouterFunction 端点构建器parameterBuilder/responseBuilder参数声明直接参与仓库 OpenAPI 契约的生成。这正是开发任务中用 SpringDoc 检查 OpenAPI spec 生成与审阅 OpenAPI 文档确保新查询参数被记录两项验证见 tasks.md的意义所在。构建后可在仓库的聚合契约中查阅该接口的最终定义例如 apis_extension.api_v1alpha1.json聚合版见 aggregated.jsonheadless / API 消费者可直接依据契约生成客户端。5. 完整行为契约含边界场景综合需求规格 spec.md该能力的行为契约可归纳如下可作为验收与二次开发的对照表#场景预期结果1已发布文章且spec.categories [java, tutorial]调用cursorByCategory(my-post)上一篇 发布时间早于当前、spec.categories包含java的最新一篇下一篇 发布时间晚于当前、包含java的最早一篇即仅按主分类java限定2文章spec.categories为空或null返回空NavigationPostVo3文章不存在或未发布返回空NavigationPostVo4主分类为java另一文章挂在子分类[spring-boot]该子分类文章不进入主分类为java的文章导航精确匹配、不级联5主分类内紧邻文章status.hideFromList true该隐藏文章不作为上一篇/下一篇继续向后找下一个符合条件的可见文章6GET /posts/my-post/navigation?scopecategory返回限定主分类的上一篇/下一篇7GET /posts/my-post/navigation无scope返回全局上一篇/下一篇既有行为其中第 5 条的场景在 PostFinderImplTest.java 中已有对应测试构造cursorByCategory(post-1)的用例第 3 条无分类文章返回空则在 PostFinderImpl.java 有直接实现。6. 测试、格式化与构建验证该变更遵循了严格的工程质量流程tasks.md 按四个阶段组织工作并全部完成Finder 层接口新增cursorByCategory、实现精确主分类过滤、无分类场景处理随后执行./gradlew spotlessApply统一代码风格REST 层PostQueryEndpoint.getPostNavigationByName接受可选scope按scopecategory路由到cursorByCategory缺省走cursor并验证参数进入 OpenAPI 契约测试层在PostFinderImplTest中为cursorByCategory新增单元测试覆盖有分类 → 分类内导航无分类 → 空结果隐藏相邻文章 → 被跳过同时更新PostQueryEndpoint的scopecategory导航测试最后以./gradlew test全量回归验证层./gradlew build整体构建通过确认既有cursor()行为无破坏性变更并复核 OpenAPI 文档中已记录新参数。7. 如何在主题与 API 中使用主题侧Thymeleaf / 模板得益于Finder(postFinder)注解实现以postFinder名称暴露给模板引擎design.md 明确Halos theme system exposes apostFinderbean to Thymeleaf templates。主题作者可在文章详情页模板中对当前文章调用${postFinder.cursorByCategory(post.metadata.name)}返回对象中取previous与next渲染上一篇/下一篇即可配合设计决策该调用对旧主题零影响——不调用即不触发分类语义。API 侧Headless / 应用直接请求前文所述带scopecategory的导航端点即可把站点前台文章底部导航留在同分类内的体验同步给小程序、App 等无头消费者。若需将当前站点从全局相邻导航平滑切换到分类内相邻导航建议的开发步骤可参考仓库内实现文件逐层落地先在 PostFinder.java 增加接口方法再在 PostFinderImpl.java 实现查询随后在 PostQueryEndpoint.java 挂接scope参数最后补齐单元测试与 OpenAPI 契约核验。8. 总结分类内导航是 Halo 面向知识库 / 文档站类站点的一项小而精准的能力增强不改动任何既有 API 语义仅通过一个全新的 Finder 方法cursorByCategory与一个可选的scopecategory查询参数就把上一篇/下一篇从全局尺度收敛到主分类尺度。其技术要点可概括为一句话的实现哲学新增而非覆盖、参数而非新路径、精确而非级联——理解这套约定即可在自己的主题或 API 集成中正确而安全地使用该特性。【免费下载链接】haloHalo 是一款强大易用的开源建站工具从个人博客、知识库到企业官网、在线商城Halo 都能助您轻松实现一站式满足您的多样化建站需求。项目地址: https://gitcode.com/GitHub_Trending/ha/halo创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考