From 482436b2d09b71f30cd8d70d035913f058a2a9c3 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Sun, 20 Apr 2025 15:56:45 +0800 Subject: [PATCH] feat: support route pattern /categories/{categorySlug}/{postSlug} for post access (#7331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /milestone 2.20.x #### What this PR does / why we need it: 文章访问路径支持设置 `/categories/{categorySlug}/{postSlug}` 的形式 #### Which issue(s) this PR fixes: Fixes #7330 #### Does this PR introduce a user-facing change? ```release-note 文章访问路径支持设置 `/categories/{categorySlug}/{postSlug}` 的形式 ``` --- .../run/halo/app/content/PostService.java | 4 ++ .../app/content/impl/PostServiceImpl.java | 10 ++++- .../permalinks/PostPermalinkPolicy.java | 10 +++++ .../core/reconciler/CategoryReconciler.java | 19 ++++++++ .../router/ThemeCompositeRouterFunction.java | 4 +- .../router/factories/PostRouteFactory.java | 27 +++++++++-- .../resources/extensions/system-setting.yaml | 2 + .../permalinks/PostPermalinkPolicyTest.java | 45 ++++++++++++++++++- 8 files changed, 114 insertions(+), 7 deletions(-) diff --git a/application/src/main/java/run/halo/app/content/PostService.java b/application/src/main/java/run/halo/app/content/PostService.java index d9344a3de..fd4eb40aa 100644 --- a/application/src/main/java/run/halo/app/content/PostService.java +++ b/application/src/main/java/run/halo/app/content/PostService.java @@ -1,8 +1,10 @@ package run.halo.app.content; +import java.util.List; import org.springframework.lang.NonNull; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.ListResult; @@ -52,4 +54,6 @@ public interface PostService { Mono deleteContent(String postName, String snapshotName); Mono recycleBy(String postName, String username); + + Flux listCategories(List categories); } diff --git a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index 6ce70871e..5b87cf944 100644 --- a/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -4,9 +4,11 @@ import static run.halo.app.extension.index.query.QueryFactory.in; import java.time.Duration; import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.function.Function; +import java.util.function.ToIntFunction; import java.util.function.UnaryOperator; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; @@ -161,13 +163,17 @@ public class PostServiceImpl extends AbstractContentService implements PostServi return client.listAll(Tag.class, listOptions, Sort.by("metadata.creationTimestamp")); } - private Flux listCategories(List categoryNames) { + @Override + public Flux listCategories(List categoryNames) { if (categoryNames == null) { return Flux.empty(); } + ToIntFunction comparator = + category -> categoryNames.indexOf(category.getMetadata().getName()); var listOptions = new ListOptions(); listOptions.setFieldSelector(FieldSelector.of(in("metadata.name", categoryNames))); - return client.listAll(Category.class, listOptions, Sort.by("metadata.creationTimestamp")); + return client.listAll(Category.class, listOptions, Sort.unsorted()) + .sort(Comparator.comparingInt(comparator)); } private Flux listContributors(List usernames) { diff --git a/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java b/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java index f903ddde8..e8f13a6d1 100644 --- a/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java +++ b/application/src/main/java/run/halo/app/content/permalinks/PostPermalinkPolicy.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Properties; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataUtil; @@ -27,12 +28,14 @@ import run.halo.app.infra.utils.PathUtils; @Component @RequiredArgsConstructor public class PostPermalinkPolicy implements PermalinkPolicy { + public static final String DEFAULT_CATEGORY = "default"; public static final String DEFAULT_PERMALINK_PATTERN = SystemSetting.ThemeRouteRules.empty().getPost(); private static final NumberFormat NUMBER_FORMAT = new DecimalFormat("00"); private final SystemConfigurableEnvironmentFetcher environmentFetcher; private final ExternalUrlSupplier externalUrlSupplier; + private final PostService postService; @Override public String permalink(Post post) { @@ -62,6 +65,13 @@ public class PostPermalinkPolicy implements PermalinkPolicy { properties.put("month", NUMBER_FORMAT.format(zonedDateTime.getMonthValue())); properties.put("day", NUMBER_FORMAT.format(zonedDateTime.getDayOfMonth())); + var categorySlug = postService.listCategories(post.getSpec().getCategories()) + .next() + .blockOptional() + .map(category -> category.getSpec().getSlug()) + .orElse(DEFAULT_CATEGORY); + properties.put("categorySlug", categorySlug); + String simplifiedPattern = PathUtils.simplifyPathPattern(pattern); String permalink = PROPERTY_PLACEHOLDER_HELPER.replacePlaceholders(simplifiedPattern, properties); diff --git a/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java b/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java index a64d462fa..6d6132cd1 100644 --- a/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java +++ b/application/src/main/java/run/halo/app/core/reconciler/CategoryReconciler.java @@ -8,18 +8,22 @@ import java.util.Map; import java.util.Set; import lombok.AllArgsConstructor; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.util.CollectionUtils; import run.halo.app.content.CategoryService; import run.halo.app.content.permalinks.CategoryPermalinkPolicy; import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; +import run.halo.app.core.extension.content.Post; import run.halo.app.event.post.CategoryHiddenStateChangeEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ListOptions; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.index.query.QueryFactory; /** * Reconciler for {@link Category}. @@ -43,6 +47,7 @@ public class CategoryReconciler implements Reconciler { if (ExtensionUtil.isDeleted(category)) { if (removeFinalizers(category.getMetadata(), Set.of(FINALIZER_NAME))) { refreshHiddenState(category, false); + updateCategoryForPost(category.getMetadata().getName()); client.update(category); } return; @@ -118,4 +123,18 @@ public class CategoryReconciler implements Reconciler { category.getStatusOrDefault() .setPermalink(categoryPermalinkPolicy.permalink(category)); } + + private void updateCategoryForPost(String categoryName) { + var posts = client.listAll(Post.class, ListOptions.builder() + .fieldQuery(QueryFactory.equal("spec.categories", categoryName)) + .build(), Sort.by("metadata.creationTimestamp", "metadata.name") + ); + for (Post post : posts) { + var categoryNames = post.getSpec().getCategories(); + if (!CollectionUtils.isEmpty(categoryNames)) { + categoryNames.remove(categoryName); + } + client.update(post); + } + } } diff --git a/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java index f1b39af50..1829ef89e 100644 --- a/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java +++ b/application/src/main/java/run/halo/app/theme/router/ThemeCompositeRouterFunction.java @@ -55,7 +55,9 @@ public class ThemeCompositeRouterFunction implements RouterFunction> route(@NonNull ServerRequest request) { return Flux.fromIterable(cachedRouters) - .concatMap(routerFunction -> routerFunction.route(request)) + .concatMap(routerFunction -> routerFunction.route(request) + .filterWhen(handle -> handle.handle(request).hasElement()) + ) .next(); } diff --git a/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java index fd7b4c2b4..4cc14ee79 100644 --- a/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java +++ b/application/src/main/java/run/halo/app/theme/router/factories/PostRouteFactory.java @@ -4,6 +4,7 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.springframework.web.reactive.function.server.RequestPredicates.GET; import static org.springframework.web.reactive.function.server.RequestPredicates.accept; +import static run.halo.app.content.permalinks.PostPermalinkPolicy.DEFAULT_CATEGORY; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; @@ -33,11 +34,11 @@ import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.server.i18n.LocaleContextResolver; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.content.PostService; import run.halo.app.core.extension.content.Post; import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.index.query.QueryFactory; -import run.halo.app.infra.exception.NotFoundException; import run.halo.app.infra.utils.JsonUtils; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.ViewNameResolver; @@ -69,6 +70,7 @@ public class PostRouteFactory implements RouteFactory { private final TitleVisibilityIdentifyCalculator titleVisibilityIdentifyCalculator; private final LocaleContextResolver localeContextResolver; + private final PostService postService; @Override public RouterFunction create(String pattern) { @@ -151,9 +153,27 @@ public class PostRouteFactory implements RouteFactory { && matchIfPresent(variable.getMonth(), labels.get(Post.ARCHIVE_MONTH_LABEL)) && matchIfPresent(variable.getDay(), labels.get(Post.ARCHIVE_DAY_LABEL)); }) + .filterWhen(post -> { + if (isNotBlank(variable.getCategorySlug())) { + var categoryNames = post.getSpec().getCategories(); + return postService.listCategories(categoryNames) + .next() + .filter(category -> category.getSpec().getSlug() + .equals(variable.getCategorySlug()) + ) + .map(category -> category.getSpec().getSlug()) + .switchIfEmpty(Mono.defer(() -> { + if (DEFAULT_CATEGORY.equals(variable.getCategorySlug())) { + return Mono.just(DEFAULT_CATEGORY); + } + return Mono.empty(); + })) + .hasElement(); + } + return Mono.just(true); + }) .next() - .flatMap(post -> postFinder.getByName(post.getMetadata().getName())) - .switchIfEmpty(Mono.error(new NotFoundException("Post not found"))); + .flatMap(post -> postFinder.getByName(post.getMetadata().getName())); } Flux postsByPredicates(PostPatternVariable patternVariable) { @@ -196,6 +216,7 @@ public class PostRouteFactory implements RouteFactory { String year; String month; String day; + String categorySlug; static PostPatternVariable from(ServerRequest request) { Map variables = mergedVariables(request); diff --git a/application/src/main/resources/extensions/system-setting.yaml b/application/src/main/resources/extensions/system-setting.yaml index 1985f904a..7c9d34ad6 100644 --- a/application/src/main/resources/extensions/system-setting.yaml +++ b/application/src/main/resources/extensions/system-setting.yaml @@ -180,6 +180,8 @@ spec: value: '/{year:\d{4}}/{month:\d{2}}/{slug}' - label: '/{year}/{month}/{day}/{slug}' value: '/{year:\d{4}}/{month:\d{2}}/{day:\d{2}}/{slug}' + - label: '/categories/{categorySlug}/{slug}' + value: '/categories/{categorySlug}/{slug}' name: post validation: required - group: codeInjection diff --git a/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java b/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java index f1655c695..10edfe1cf 100644 --- a/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java +++ b/application/src/test/java/run/halo/app/content/permalinks/PostPermalinkPolicyTest.java @@ -1,6 +1,7 @@ package run.halo.app.content.permalinks; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.when; @@ -10,6 +11,7 @@ import java.text.NumberFormat; import java.time.Instant; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.util.List; import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -17,9 +19,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationContext; +import reactor.core.publisher.Flux; +import run.halo.app.content.PostService; import run.halo.app.content.TestPost; +import run.halo.app.core.extension.content.Category; import run.halo.app.core.extension.content.Constant; import run.halo.app.core.extension.content.Post; +import run.halo.app.extension.Metadata; import run.halo.app.extension.MetadataUtil; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; @@ -44,12 +50,17 @@ class PostPermalinkPolicyTest { @Mock private SystemConfigurableEnvironmentFetcher environmentFetcher; + @Mock + private PostService postService; + private PostPermalinkPolicy postPermalinkPolicy; @BeforeEach void setUp() { lenient().when(externalUrlSupplier.get()).thenReturn(URI.create("")); - postPermalinkPolicy = new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier); + lenient().when(postService.listCategories(any())).thenReturn(Flux.empty()); + postPermalinkPolicy = + new PostPermalinkPolicy(environmentFetcher, externalUrlSupplier, postService); } @Test @@ -93,6 +104,24 @@ class PostPermalinkPolicyTest { assertThat(permalink).isEqualTo("/posts/test-post"); } + @Test + void permalinkForCategory() { + Post post = TestPost.postV1(); + post.getSpec().setCategories(List.of("test-category")); + Map annotations = MetadataUtil.nullSafeAnnotations(post); + annotations.put(Constant.PERMALINK_PATTERN_ANNO, "/{categorySlug}/{slug}"); + post.getMetadata().setName("test-post"); + post.getSpec().setSlug("test-post-slug"); + Instant now = Instant.now(); + post.getSpec().setPublishTime(now); + + var category = createCategory("test-category", "test-category-slug"); + when(postService.listCategories(post.getSpec().getCategories())) + .thenReturn(Flux.just(category)); + var permalink = postPermalinkPolicy.permalink(post); + assertThat(permalink).isEqualTo("/test-category-slug/test-post-slug"); + } + @Test void permalinkWithExternalUrl() { Post post = TestPost.postV1(); @@ -112,4 +141,18 @@ class PostPermalinkPolicyTest { permalink = postPermalinkPolicy.permalink(post); assertThat(permalink).isEqualTo("http://example.com/2022/11/01/%E4%B8%AD%E6%96%87%20slug"); } + + private Category createCategory(String name, String slug) { + Category category = new Category(); + Metadata metadata = new Metadata(); + metadata.setName(name); + category.setMetadata(metadata); + category.setSpec(new Category.CategorySpec()); + category.setStatus(new Category.CategoryStatus()); + + category.getSpec().setDisplayName("display-name"); + category.getSpec().setSlug(slug); + category.getSpec().setPriority(0); + return category; + } } \ No newline at end of file