diff --git a/application/src/main/java/run/halo/app/content/comment/CommentQuery.java b/application/src/main/java/run/halo/app/content/comment/CommentQuery.java index a84ea2cef2..ea44ae87c0 100644 --- a/application/src/main/java/run/halo/app/content/comment/CommentQuery.java +++ b/application/src/main/java/run/halo/app/content/comment/CommentQuery.java @@ -56,13 +56,16 @@ public String getOwnerName() { @ArraySchema(uniqueItems = true, arraySchema = @Schema(name = "sort", description = "Sort property and direction of the list result. Supported fields: " - + "creationTimestamp,replyCount,lastReplyTime"), + + "metadata.creationTimestamp,status.replyCount,status.lastReplyTime"), schema = @Schema(description = "like field,asc or field,desc", implementation = String.class, example = "creationTimestamp,desc")) public Sort getSort() { var sort = SortResolver.defaultInstance.resolve(exchange); - return sort.and(Sort.by("spec.creationTime", "metadata.name").descending()); + return sort.and(Sort.by("status.lastReplyTime", + "spec.creationTime", + "metadata.name" + ).descending()); } public PageRequest toPageRequest() { diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java b/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java index 1f4bec6a95..b32c33e1af 100644 --- a/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java +++ b/application/src/main/java/run/halo/app/content/comment/ReplyQuery.java @@ -1,10 +1,18 @@ package run.halo.app.content.comment; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToListOptions; + import io.swagger.v3.oas.annotations.media.Schema; import org.apache.commons.lang3.StringUtils; -import org.springframework.util.MultiValueMap; +import org.springframework.data.domain.Sort; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; import run.halo.app.core.extension.content.Reply; -import run.halo.app.extension.router.IListRequest; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequest; +import run.halo.app.extension.PageRequestImpl; +import run.halo.app.extension.router.SortableRequest; /** * Query criteria for {@link Reply} list. @@ -12,15 +20,35 @@ * @author guqing * @since 2.0.0 */ -public class ReplyQuery extends IListRequest.QueryListRequest { +public class ReplyQuery extends SortableRequest { - public ReplyQuery(MultiValueMap queryParams) { - super(queryParams); + public ReplyQuery(ServerWebExchange exchange) { + super(exchange); } @Schema(description = "Replies filtered by commentName.") public String getCommentName() { String commentName = queryParams.getFirst("commentName"); - return StringUtils.isBlank(commentName) ? null : commentName; + if (StringUtils.isBlank(commentName)) { + throw new ServerWebInputException("The required parameter 'commentName' is missing."); + } + return commentName; + } + + /** + * Build list options from query criteria. + */ + public ListOptions toListOptions() { + var listOptions = + labelAndFieldSelectorToListOptions(getLabelSelector(), getFieldSelector()); + var newFieldSelector = listOptions.getFieldSelector() + .andQuery(equal("spec.commentName", getCommentName())); + listOptions.setFieldSelector(newFieldSelector); + return listOptions; + } + + public PageRequest toPageRequest() { + var sort = getSort().and(Sort.by("spec.creationTime").ascending()); + return PageRequestImpl.of(getPage(), getSize(), sort); } } diff --git a/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java index d1a929405c..ba23b21a21 100644 --- a/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java +++ b/application/src/main/java/run/halo/app/content/comment/ReplyServiceImpl.java @@ -79,9 +79,7 @@ public Mono create(String commentName, Reply reply) { @Override public Mono> list(ReplyQuery query) { - return client.list(Reply.class, getReplyPredicate(query), - ReplyService.creationTimeAscComparator(), - query.getPage(), query.getSize()) + return client.listBy(Reply.class, query.toListOptions(), query.toPageRequest()) .flatMap(list -> Flux.fromStream(list.get() .map(this::toListedReply)) .concatMap(Function.identity()) diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java index fd2c6c066b..8e09e9b357 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/ReplyEndpoint.java @@ -48,7 +48,7 @@ public RouterFunction endpoint() { } Mono listReplies(ServerRequest request) { - ReplyQuery replyQuery = new ReplyQuery(request.queryParams()); + ReplyQuery replyQuery = new ReplyQuery(request.exchange()); return replyService.list(replyQuery) .flatMap(listedReplies -> ServerResponse.ok().bodyValue(listedReplies)); } diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java index 8d3a535216..97e9d59a30 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/ReplyReconciler.java @@ -1,5 +1,6 @@ package run.halo.app.core.extension.reconciler; +import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.ExtensionUtil.addFinalizers; import java.util.Set; @@ -45,6 +46,15 @@ public Result reconcile(Request request) { eventPublisher.publishEvent(new ReplyCreatedEvent(this, reply)); } + if (reply.getSpec().getCreationTime() == null) { + reply.getSpec().setCreationTime( + defaultIfNull(reply.getSpec().getApprovedTime(), + reply.getMetadata().getCreationTimestamp() + ) + ); + } + client.update(reply); + replyNotificationSubscriptionHelper.subscribeNewReplyReasonForReply(reply); eventPublisher.publishEvent(new ReplyChangedEvent(this, reply)); diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index 5dc85c32f8..e2d1c692a9 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -223,7 +223,8 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event indexSpecs.add(new IndexSpec() .setName("spec.creationTime") .setIndexFunc(simpleAttribute(Comment.class, - comment -> comment.getSpec().getCreationTime().toString()) + comment -> defaultIfNull(comment.getSpec().getCreationTime(), + comment.getMetadata().getCreationTimestamp()).toString()) )); indexSpecs.add(new IndexSpec() .setName("spec.approved") @@ -282,7 +283,35 @@ public void onApplicationEvent(@NonNull ApplicationContextInitializedEvent event return defaultIfNull(replyCount, 0).toString(); }))); }); - schemeManager.register(Reply.class); + schemeManager.register(Reply.class, indexSpecs -> { + indexSpecs.add(new IndexSpec() + .setName("spec.creationTime") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> defaultIfNull(reply.getSpec().getCreationTime(), + reply.getMetadata().getCreationTimestamp()).toString()) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.commentName") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> reply.getSpec().getCommentName()) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.hidden") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> toStringTrueFalse(isTrue(reply.getSpec().getHidden()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.approved") + .setIndexFunc(simpleAttribute(Reply.class, + reply -> toStringTrueFalse(isTrue(reply.getSpec().getApproved()))) + )); + indexSpecs.add(new IndexSpec() + .setName("spec.owner") + .setIndexFunc(simpleAttribute(Reply.class, reply -> { + var owner = reply.getSpec().getOwner(); + return Comment.CommentOwner.ownerIdentity(owner.getKind(), owner.getName()); + }))); + }); schemeManager.register(SinglePage.class); // storage.halo.run schemeManager.register(Group.class); diff --git a/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java b/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java index 6e41321c61..69f9c6734e 100644 --- a/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java +++ b/application/src/main/java/run/halo/app/metrics/ReplyEventReconciler.java @@ -1,28 +1,35 @@ package run.halo.app.metrics; -import static org.apache.commons.lang3.BooleanUtils.isFalse; -import static org.apache.commons.lang3.BooleanUtils.isTrue; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; +import static run.halo.app.extension.index.query.QueryFactory.and; +import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.greaterThan; +import static run.halo.app.extension.index.query.QueryFactory.isNull; import java.time.Duration; import java.time.Instant; -import java.util.List; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.BooleanUtils; import org.springframework.context.SmartLifecycle; import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; -import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; import run.halo.app.event.post.ReplyEvent; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.ListOptions; +import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.controller.Controller; import run.halo.app.extension.controller.ControllerBuilder; import run.halo.app.extension.controller.DefaultController; import run.halo.app.extension.controller.DefaultQueue; import run.halo.app.extension.controller.Reconciler; import run.halo.app.extension.controller.RequestQueue; +import run.halo.app.extension.index.query.Query; +import run.halo.app.extension.router.selector.FieldSelector; /** * Update the comment status after receiving the reply event. @@ -54,35 +61,48 @@ public Result reconcile(ReplyEvent request) { // if the comment has been deleted, then do nothing. .filter(comment -> comment.getMetadata().getDeletionTimestamp() == null) .ifPresent(comment -> { - // order by reply creation time desc to get first as last reply time - List replies = client.list(Reply.class, - record -> commentName.equals(record.getSpec().getCommentName()) - && record.getMetadata().getDeletionTimestamp() == null, - ReplyService.creationTimeAscComparator().reversed()); - - Comment.CommentStatus status = comment.getStatusOrDefault(); + var baseQuery = and( + equal("spec.commentName", commentName), + isNull("metadata.deletionTimestamp") + ); + var pageRequest = PageRequestImpl.ofSize(1).withSort( + Sort.by("spec.creationTime", "metadata.name").descending() + ); + final Comment.CommentStatus status = comment.getStatusOrDefault(); + + var replyPageResult = + client.listBy(Reply.class, listOptionsWithFieldQuery(baseQuery), pageRequest); // total reply count - status.setReplyCount(replies.size()); - - long visibleReplyCount = replies.stream() - .filter(reply -> isTrue(reply.getSpec().getApproved()) - && isFalse(reply.getSpec().getHidden()) - ) - .count(); - status.setVisibleReplyCount((int) visibleReplyCount); + status.setReplyCount((int) replyPageResult.getTotal()); - // calculate last reply time - Instant lastReplyTime = replies.stream() + // calculate last reply time from total replies(top 1) + Instant lastReplyTime = replyPageResult.get() + .map(reply -> reply.getSpec().getCreationTime()) .findFirst() - .map(reply -> defaultIfNull(reply.getSpec().getCreationTime(), - reply.getMetadata().getCreationTimestamp()) - ) .orElse(null); status.setLastReplyTime(lastReplyTime); - Instant lastReadTime = comment.getSpec().getLastReadTime(); - status.setUnreadReplyCount(Comment.getUnreadReplyCount(replies, lastReadTime)); + // calculate visible reply count(only approved and not hidden) + var visibleReplyPageResult = + client.listBy(Reply.class, listOptionsWithFieldQuery(and( + baseQuery, + equal("spec.approved", BooleanUtils.TRUE), + equal("spec.hidden", BooleanUtils.FALSE) + )), pageRequest); + status.setVisibleReplyCount((int) visibleReplyPageResult.getTotal()); + + // calculate unread reply count(after last read time) + var unReadQuery = Optional.ofNullable(comment.getSpec().getLastReadTime()) + .map(lastReadTime -> and( + baseQuery, + greaterThan("spec.creationTime", lastReadTime.toString()) + )) + .orElse(baseQuery); + var unReadPageResult = + client.listBy(Reply.class, listOptionsWithFieldQuery(unReadQuery), pageRequest); + status.setUnreadReplyCount((int) unReadPageResult.getTotal()); + status.setHasNewReply(defaultIfNull(status.getUnreadReplyCount(), 0) > 0); client.update(comment); @@ -90,6 +110,12 @@ && isFalse(reply.getSpec().getHidden()) return new Result(false, null); } + static ListOptions listOptionsWithFieldQuery(Query query) { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(FieldSelector.of(query)); + return listOptions; + } + @Override public Controller setupWith(ControllerBuilder builder) { return new DefaultController<>( diff --git a/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java b/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java index ab00919ebf..db0123ee51 100644 --- a/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java +++ b/application/src/main/java/run/halo/app/theme/finders/CommentPublicQueryService.java @@ -1,9 +1,7 @@ package run.halo.app.theme.finders; -import java.util.Comparator; import org.springframework.lang.Nullable; import reactor.core.publisher.Mono; -import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.ListResult; import run.halo.app.extension.PageRequest; import run.halo.app.extension.Ref; @@ -26,6 +24,5 @@ Mono> list(Ref ref, @Nullable Integer page, Mono> listReply(String commentName, @Nullable Integer page, @Nullable Integer size); - Mono> listReply(String commentName, @Nullable Integer page, - @Nullable Integer size, @Nullable Comparator comparator); + Mono> listReply(String commentName, PageRequest pageRequest); } diff --git a/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java index 34bdf2bf5f..bf3bc03939 100644 --- a/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java +++ b/application/src/main/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImpl.java @@ -4,17 +4,14 @@ import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static run.halo.app.extension.index.query.QueryFactory.and; import static run.halo.app.extension.index.query.QueryFactory.equal; +import static run.halo.app.extension.index.query.QueryFactory.isNull; import static run.halo.app.extension.index.query.QueryFactory.or; import java.security.Principal; -import java.util.Comparator; -import java.util.List; import java.util.Optional; import java.util.function.Function; -import java.util.function.Predicate; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.BooleanUtils; -import org.apache.commons.lang3.StringUtils; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; import org.springframework.security.core.context.ReactiveSecurityContextHolder; @@ -24,7 +21,6 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.comment.OwnerInfo; -import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Reply; @@ -36,7 +32,7 @@ import run.halo.app.extension.PageRequestImpl; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; -import run.halo.app.extension.index.query.QueryFactory; +import run.halo.app.extension.index.query.Query; import run.halo.app.extension.router.selector.FieldSelector; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.metrics.CounterService; @@ -70,18 +66,19 @@ public Mono getByName(String name) { @Override public Mono> list(Ref ref, Integer page, Integer size) { - return list(ref, PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultSort())); + return list(ref, + PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), defaultCommentSort())); } @Override public Mono> list(Ref ref, PageRequest pageParam) { var pageRequest = Optional.ofNullable(pageParam) - .map(page -> page.withSort(page.getSort().and(defaultSort()))) + .map(page -> page.withSort(page.getSort().and(defaultCommentSort()))) .orElse(PageRequestImpl.ofSize(0)); - return fixedCommentFieldQuery(ref) - .flatMap(fixedFieldQuery -> { + return fixedCommentFieldSelector(ref) + .flatMap(fieldSelector -> { var listOptions = new ListOptions(); - listOptions.setFieldSelector(fixedFieldQuery); + listOptions.setFieldSelector(fieldSelector); return client.listBy(Comment.class, listOptions, pageRequest) .flatMap(listResult -> Flux.fromStream(listResult.get()) .map(this::toCommentVo) @@ -99,26 +96,29 @@ public Mono> list(Ref ref, PageRequest pageParam) { @Override public Mono> listReply(String commentName, Integer page, Integer size) { - return listReply(commentName, page, size, ReplyService.creationTimeAscComparator()); + return listReply(commentName, PageRequestImpl.of(pageNullSafe(page), sizeNullSafe(size), + defaultReplySort())); } @Override - public Mono> listReply(String commentName, Integer page, Integer size, - Comparator comparator) { - return fixedReplyPredicate(commentName) - .flatMap(fixedPredicate -> - client.list(Reply.class, fixedPredicate, - comparator, - pageNullSafe(page), sizeNullSafe(size)) + public Mono> listReply(String commentName, PageRequest pageParam) { + return fixedReplyFieldSelector(commentName) + .flatMap(fieldSelector -> { + var listOptions = new ListOptions(); + listOptions.setFieldSelector(fieldSelector); + var pageRequest = Optional.ofNullable(pageParam) + .map(page -> page.withSort(page.getSort().and(defaultReplySort()))) + .orElse(PageRequestImpl.ofSize(0)); + return client.listBy(Reply.class, listOptions, pageRequest) .flatMap(list -> Flux.fromStream(list.get().map(this::toReplyVo)) .concatMap(Function.identity()) .collectList() .map(replyVos -> new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), replyVos)) - ) - .defaultIfEmpty(new ListResult<>(page, size, 0L, List.of())) - ); + ); + }) + .defaultIfEmpty(ListResult.emptyResult()); } Mono toCommentVo(Comment comment) { @@ -203,10 +203,10 @@ private Mono getOwnerInfo(Comment.CommentOwner owner) { .map(OwnerInfo::from); } - private Mono fixedCommentFieldQuery(@Nullable Ref ref) { + private Mono fixedCommentFieldSelector(@Nullable Ref ref) { return Mono.fromSupplier( () -> { - var baseQuery = QueryFactory.isNull("metadata.deletionTimestamp"); + var baseQuery = isNull("metadata.deletionTimestamp"); if (ref != null) { baseQuery = and(baseQuery, @@ -214,43 +214,35 @@ private Mono fixedCommentFieldQuery(@Nullable Ref ref) { } return baseQuery; }) - .flatMap(query -> { - var approvedQuery = and( - equal("spec.approved", BooleanUtils.TRUE), - equal("spec.hidden", BooleanUtils.FALSE) - ); - // we should list all comments that the user owns - return getCurrentUserWithoutAnonymous() - .map(username -> or(approvedQuery, equal("spec.owner", - Comment.CommentOwner.ownerIdentity(User.KIND, username))) - ) - .defaultIfEmpty(approvedQuery) - .map(compositeQuery -> and(query, compositeQuery)); - }) + .flatMap(this::concatVisibleQuery) .map(FieldSelector::of); } - private Mono> fixedReplyPredicate(String commentName) { + private Mono concatVisibleQuery(Query query) { + Assert.notNull(query, "The query must not be null"); + var approvedQuery = and( + equal("spec.approved", BooleanUtils.TRUE), + equal("spec.hidden", BooleanUtils.FALSE) + ); + // we should list all comments that the user owns + return getCurrentUserWithoutAnonymous() + .map(username -> or(approvedQuery, equal("spec.owner", + Comment.CommentOwner.ownerIdentity(User.KIND, username))) + ) + .defaultIfEmpty(approvedQuery) + .map(compositeQuery -> and(query, compositeQuery)); + } + + private Mono fixedReplyFieldSelector(String commentName) { Assert.notNull(commentName, "The commentName must not be null"); // The comment name must be equal to the comment name of the reply - Predicate commentNamePredicate = - reply -> reply.getSpec().getCommentName().equals(commentName) - && reply.getMetadata().getDeletionTimestamp() == null; - // is approved and not hidden - Predicate approvedPredicate = - reply -> BooleanUtils.isFalse(reply.getSpec().getHidden()) - && BooleanUtils.isTrue(reply.getSpec().getApproved()); - return getCurrentUserWithoutAnonymous() - .map(username -> { - Predicate isOwner = reply -> { - Comment.CommentOwner owner = reply.getSpec().getOwner(); - return owner != null && StringUtils.equals(username, owner.getName()); - }; - return approvedPredicate.or(isOwner); - }) - .defaultIfEmpty(approvedPredicate) - .map(commentNamePredicate::and); + return Mono.fromSupplier(() -> and( + equal("spec.commentName", commentName), + isNull("metadata.deletionTimestamp") + )) + .flatMap(this::concatVisibleQuery) + .map(FieldSelector::of); } Mono getCurrentUserWithoutAnonymous() { @@ -260,7 +252,7 @@ Mono getCurrentUserWithoutAnonymous() { .filter(username -> !AnonymousUserConst.PRINCIPAL.equals(username)); } - static Sort defaultSort() { + static Sort defaultCommentSort() { return Sort.by(Sort.Order.desc("spec.top"), Sort.Order.asc("spec.priority"), Sort.Order.desc("spec.creationTime"), @@ -268,6 +260,12 @@ static Sort defaultSort() { ); } + static Sort defaultReplySort() { + return Sort.by(Sort.Order.asc("spec.creationTime"), + Sort.Order.asc("metadata.name") + ); + } + int pageNullSafe(Integer page) { return defaultIfNull(page, 1); } diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java index 3a283fdbe2..f1c883340f 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceImplTest.java @@ -1,6 +1,5 @@ package run.halo.app.theme.finders.impl; -import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -19,15 +18,12 @@ import org.mockito.Mock; import org.mockito.stubbing.Answer; import org.skyscreamer.jsonassert.JSONAssert; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.junit.jupiter.SpringExtension; import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; -import run.halo.app.core.extension.content.Reply; import run.halo.app.core.extension.service.UserService; import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.ListResult; @@ -214,190 +210,6 @@ Comment createComment() { } } - @Nested - class ListReplyTest { - @Test - void listWhenUserNotLogin() { - // Mock - mockWhenListRely(); - - commentPublicQueryService.listReply("fake-comment", 1, 10) - .as(StepVerifier::create) - .consumeNextWith(listResult -> { - assertThat(listResult.getTotal()).isEqualTo(2); - assertThat(listResult.getItems().size()).isEqualTo(2); - assertThat(listResult.getItems().get(0).getMetadata().getName()) - .isEqualTo("reply-approved"); - assertThat(listResult.getItems().get(0).getStats().getUpvote()).isEqualTo(9); - }) - .verifyComplete(); - } - - @Test - @WithMockUser(username = AnonymousUserConst.PRINCIPAL) - void listWhenUserIsAnonymous() { - // Mock - mockWhenListRely(); - - commentPublicQueryService.listReply("fake-comment", 1, 10) - .as(StepVerifier::create) - .consumeNextWith(listResult -> { - assertThat(listResult.getTotal()).isEqualTo(2); - assertThat(listResult.getItems().size()).isEqualTo(2); - assertThat(listResult.getItems().get(0).getMetadata().getName()) - .isEqualTo("reply-approved"); - }) - .verifyComplete(); - } - - @Test - @WithMockUser(username = "fake-user") - void listWhenUserLoggedIn() { - mockWhenListRely(); - - commentPublicQueryService.listReply("fake-comment", 1, 10) - .as(StepVerifier::create) - .consumeNextWith(listResult -> { - assertThat(listResult.getTotal()).isEqualTo(3); - assertThat(listResult.getItems().size()).isEqualTo(3); - assertThat(listResult.getItems().get(0).getMetadata().getName()) - .isEqualTo("reply-not-approved"); - assertThat(listResult.getItems().get(1).getMetadata().getName()) - .isEqualTo("reply-approved"); - }) - .verifyComplete(); - } - - @Test - void desensitizeReply() throws JSONException { - var reply = createReply(); - reply.getSpec().getOwner() - .setAnnotations(new HashMap<>() { - { - put(Comment.CommentOwner.KIND_EMAIL, "mail@halo.run"); - } - }); - reply.getSpec().setIpAddress("127.0.0.1"); - - Counter counter = new Counter(); - counter.setUpvote(0); - when(counterService.getByName(any())).thenReturn(Mono.just(counter)); - - var result = commentPublicQueryService.toReplyVo(reply).block(); - result.getMetadata().setCreationTimestamp(null); - result.getSpec().setCreationTime(null); - JSONAssert.assertEquals(""" - { - "metadata":{ - "name":"fake-reply" - }, - "spec":{ - "raw":"fake-raw", - "content":"fake-content", - "owner":{ - "kind":"User", - "name":"", - "displayName":"fake-display-name", - "annotations":{ - - } - }, - "ipAddress":"", - "hidden":false, - "commentName":"fake-comment" - }, - "owner":{ - "kind":"User", - "displayName":"fake-display-name" - }, - "stats":{ - "upvote":0 - } - } - """, - JsonUtils.objectToJson(result), - true); - } - - @SuppressWarnings("unchecked") - private void mockWhenListRely() { - // Mock - Reply notApproved = createReply(); - notApproved.getMetadata().setName("reply-not-approved"); - notApproved.getSpec().setApproved(false); - - Reply approved = createReply(); - approved.getMetadata().setName("reply-approved"); - approved.getSpec().setApproved(true); - - Reply notApprovedWithAnonymous = createReply(); - notApprovedWithAnonymous.getMetadata().setName("reply-not-approved-anonymous"); - notApprovedWithAnonymous.getSpec().setApproved(false); - notApprovedWithAnonymous.getSpec().getOwner().setName(AnonymousUserConst.PRINCIPAL); - - Reply approvedButAnotherOwner = createReply(); - approvedButAnotherOwner.getMetadata() - .setName("reply-approved-but-another-owner"); - approvedButAnotherOwner.getSpec().setApproved(true); - approvedButAnotherOwner.getSpec().getOwner().setName("another"); - - Reply notApprovedAndAnotherOwner = createReply(); - notApprovedAndAnotherOwner.getMetadata() - .setName("reply-not-approved-and-another"); - notApprovedAndAnotherOwner.getSpec().setApproved(false); - notApprovedAndAnotherOwner.getSpec().getOwner().setName("another"); - - Reply notApprovedAndAnotherCommentName = createReply(); - notApprovedAndAnotherCommentName.getMetadata() - .setName("reply-approved-and-another-comment-name"); - notApprovedAndAnotherCommentName.getSpec().setApproved(false); - notApprovedAndAnotherCommentName.getSpec().setCommentName("another-fake-comment"); - - when(client.list(eq(Reply.class), any(), - any(), - eq(1), - eq(10)) - ).thenAnswer((Answer>>) invocation -> { - Predicate predicate = - invocation.getArgument(1, Predicate.class); - List replies = Stream.of( - notApproved, - approved, - approvedButAnotherOwner, - notApprovedAndAnotherOwner, - notApprovedWithAnonymous, - notApprovedAndAnotherCommentName - ).filter(predicate).toList(); - return Mono.just(new ListResult<>(1, 10, replies.size(), replies)); - }); - - extractedUser(); - when(client.fetch(eq(User.class), any())).thenReturn(Mono.just(createUser())); - - Counter counter = new Counter(); - counter.setUpvote(9); - when(counterService.getByName(any())).thenReturn(Mono.just(counter)); - } - - Reply createReply() { - Reply reply = new Reply(); - reply.setMetadata(new Metadata()); - reply.getMetadata().setName("fake-reply"); - reply.setSpec(new Reply.ReplySpec()); - - reply.getSpec().setRaw("fake-raw"); - reply.getSpec().setContent("fake-content"); - reply.getSpec().setHidden(false); - reply.getSpec().setCommentName("fake-comment"); - Comment.CommentOwner commentOwner = new Comment.CommentOwner(); - commentOwner.setKind(User.KIND); - commentOwner.setName("fake-user"); - commentOwner.setDisplayName("fake-display-name"); - reply.getSpec().setOwner(commentOwner); - return reply; - } - } - private void extractedUser() { User another = createUser(); another.getMetadata().setName("another"); diff --git a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java index 43074706af..3258ac08e2 100644 --- a/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java +++ b/application/src/test/java/run/halo/app/theme/finders/impl/CommentPublicQueryServiceIntegrationTest.java @@ -2,13 +2,18 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import java.time.Instant; +import java.util.HashMap; import java.util.List; import java.util.stream.Collectors; +import org.json.JSONException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.test.context.support.WithMockUser; @@ -19,6 +24,7 @@ import run.halo.app.core.extension.User; import run.halo.app.core.extension.content.Comment; import run.halo.app.core.extension.content.Post; +import run.halo.app.core.extension.content.Reply; import run.halo.app.extension.Extension; import run.halo.app.extension.ExtensionStoreUtil; import run.halo.app.extension.GroupVersionKind; @@ -223,7 +229,7 @@ void tearDown() { void sortTest() { var comments = client.listAll(Comment.class, new ListOptions(), - CommentPublicQueryServiceImpl.defaultSort()) + CommentPublicQueryServiceImpl.defaultCommentSort()) .collectList() .block(); assertThat(comments).isNotNull(); @@ -279,6 +285,192 @@ Comment commentForCompare(String name, Instant creationTime, boolean top, int pr } } + @Nested + class ListReplyTest { + private final List storedReplies = mockRelies(); + @Autowired + private CommentPublicQueryServiceImpl commentPublicQueryService; + + @BeforeEach + void setUp() { + Flux.fromIterable(storedReplies) + .flatMap(reply -> client.create(reply)) + .as(StepVerifier::create) + .expectNextCount(storedReplies.size()) + .verifyComplete(); + } + + @AfterEach + void tearDown() { + Flux.fromIterable(storedReplies) + .flatMap(CommentPublicQueryServiceIntegrationTest.this::deleteImmediately) + .as(StepVerifier::create) + .expectNextCount(storedReplies.size()) + .verifyComplete(); + } + + @Test + void listWhenUserNotLogin() { + commentPublicQueryService.listReply("fake-comment", 1, 10) + .as(StepVerifier::create) + .consumeNextWith(listResult -> { + assertThat(listResult.getTotal()).isEqualTo(2); + assertThat(listResult.getItems().size()).isEqualTo(2); + assertThat(listResult.getItems().get(0).getMetadata().getName()) + .isEqualTo("reply-approved"); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(username = AnonymousUserConst.PRINCIPAL) + void listWhenUserIsAnonymous() { + commentPublicQueryService.listReply("fake-comment", 1, 10) + .as(StepVerifier::create) + .consumeNextWith(listResult -> { + assertThat(listResult.getTotal()).isEqualTo(2); + assertThat(listResult.getItems().size()).isEqualTo(2); + assertThat(listResult.getItems().get(0).getMetadata().getName()) + .isEqualTo("reply-approved"); + }) + .verifyComplete(); + } + + @Test + @WithMockUser(username = "fake-user") + void listWhenUserLoggedIn() { + commentPublicQueryService.listReply("fake-comment", 1, 10) + .as(StepVerifier::create) + .consumeNextWith(listResult -> { + assertThat(listResult.getTotal()).isEqualTo(3); + assertThat(listResult.getItems().size()).isEqualTo(3); + assertThat(listResult.getItems().get(0).getMetadata().getName()) + .isEqualTo("reply-approved"); + assertThat(listResult.getItems().get(1).getMetadata().getName()) + .isEqualTo("reply-approved-but-another-owner"); + assertThat(listResult.getItems().get(2).getMetadata().getName()) + .isEqualTo("reply-not-approved"); + }) + .verifyComplete(); + } + + @Test + void desensitizeReply() throws JSONException { + var reply = createReply(); + reply.getSpec().getOwner() + .setAnnotations(new HashMap<>() { + { + put(Comment.CommentOwner.KIND_EMAIL, "mail@halo.run"); + } + }); + reply.getSpec().setIpAddress("127.0.0.1"); + + var result = commentPublicQueryService.toReplyVo(reply).block(); + result.getMetadata().setCreationTimestamp(null); + var jsonObject = JsonUtils.jsonToObject(fakeReplyJson(), JsonNode.class); + ((ObjectNode) jsonObject.get("owner")) + .put("displayName", "已删除用户"); + JSONAssert.assertEquals(jsonObject.toString(), + JsonUtils.objectToJson(result), + true); + } + + String fakeReplyJson() { + return """ + { + "metadata":{ + "name":"fake-reply" + }, + "spec":{ + "raw":"fake-raw", + "content":"fake-content", + "owner":{ + "kind":"User", + "name":"", + "displayName":"fake-display-name", + "annotations":{ + + } + }, + "creationTime": "2024-03-11T06:23:42.923294424Z", + "ipAddress":"", + "hidden": false, + "allowNotification": false, + "top": false, + "priority": 0, + "commentName":"fake-comment" + }, + "owner":{ + "kind":"User", + "displayName":"fake-display-name" + }, + "stats":{ + "upvote":0 + } + } + """; + } + + private List mockRelies() { + // Mock + Reply notApproved = createReply(); + notApproved.getMetadata().setName("reply-not-approved"); + notApproved.getSpec().setApproved(false); + + Reply approved = createReply(); + approved.getMetadata().setName("reply-approved"); + approved.getSpec().setApproved(true); + + Reply notApprovedWithAnonymous = createReply(); + notApprovedWithAnonymous.getMetadata().setName("reply-not-approved-anonymous"); + notApprovedWithAnonymous.getSpec().setApproved(false); + notApprovedWithAnonymous.getSpec().getOwner().setName(AnonymousUserConst.PRINCIPAL); + + Reply approvedButAnotherOwner = createReply(); + approvedButAnotherOwner.getMetadata() + .setName("reply-approved-but-another-owner"); + approvedButAnotherOwner.getSpec().setApproved(true); + approvedButAnotherOwner.getSpec().getOwner().setName("another"); + + Reply notApprovedAndAnotherOwner = createReply(); + notApprovedAndAnotherOwner.getMetadata() + .setName("reply-not-approved-and-another"); + notApprovedAndAnotherOwner.getSpec().setApproved(false); + notApprovedAndAnotherOwner.getSpec().getOwner().setName("another"); + + Reply notApprovedAndAnotherCommentName = createReply(); + notApprovedAndAnotherCommentName.getMetadata() + .setName("reply-approved-and-another-comment-name"); + notApprovedAndAnotherCommentName.getSpec().setApproved(false); + notApprovedAndAnotherCommentName.getSpec().setCommentName("another-fake-comment"); + + return List.of( + notApproved, + approved, + approvedButAnotherOwner, + notApprovedAndAnotherOwner, + notApprovedWithAnonymous, + notApprovedAndAnotherCommentName + ); + } + + Reply createReply() { + var reply = JsonUtils.jsonToObject(fakeReplyJson(), Reply.class); + reply.getMetadata().setName("fake-reply"); + + reply.getSpec().setRaw("fake-raw"); + reply.getSpec().setContent("fake-content"); + reply.getSpec().setHidden(false); + reply.getSpec().setCommentName("fake-comment"); + Comment.CommentOwner commentOwner = new Comment.CommentOwner(); + commentOwner.setKind(User.KIND); + commentOwner.setName("fake-user"); + commentOwner.setDisplayName("fake-display-name"); + reply.getSpec().setOwner(commentOwner); + return reply; + } + } + Comment createComment() { return JsonUtils.jsonToObject(""" { diff --git a/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-comment-api.ts b/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-comment-api.ts index 85c796a256..c4a18cccfe 100644 --- a/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-comment-api.ts +++ b/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-comment-api.ts @@ -186,7 +186,7 @@ export const ApiConsoleHaloRunV1alpha1CommentApiAxiosParamCreator = function ( * @param {string} [ownerName] Commenter name. * @param {number} [page] The page number. Zero indicates no page. * @param {number} [size] Size of one page. Zero indicates no limit. - * @param {Array} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,replyCount,lastReplyTime + * @param {Array} [sort] Sort property and direction of the list result. Supported fields: metadata.creationTimestamp,status.replyCount,status.lastReplyTime * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -342,7 +342,7 @@ export const ApiConsoleHaloRunV1alpha1CommentApiFp = function ( * @param {string} [ownerName] Commenter name. * @param {number} [page] The page number. Zero indicates no page. * @param {number} [size] Size of one page. Zero indicates no limit. - * @param {Array} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp,replyCount,lastReplyTime + * @param {Array} [sort] Sort property and direction of the list result. Supported fields: metadata.creationTimestamp,status.replyCount,status.lastReplyTime * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -544,7 +544,7 @@ export interface ApiConsoleHaloRunV1alpha1CommentApiListCommentsRequest { readonly size?: number; /** - * Sort property and direction of the list result. Supported fields: creationTimestamp,replyCount,lastReplyTime + * Sort property and direction of the list result. Supported fields: metadata.creationTimestamp,status.replyCount,status.lastReplyTime * @type {Array} * @memberof ApiConsoleHaloRunV1alpha1CommentApiListComments */ diff --git a/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-reply-api.ts b/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-reply-api.ts index 0f4f494682..475e1c557f 100644 --- a/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-reply-api.ts +++ b/ui/packages/api-client/src/api/api-console-halo-run-v1alpha1-reply-api.ts @@ -54,6 +54,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiAxiosParamCreator = function ( * @param {Array} [labelSelector] Label selector for filtering. * @param {number} [page] The page number. Zero indicates no page. * @param {number} [size] Size of one page. Zero indicates no limit. + * @param {Array} [sort] Sort property and direction of the list result. Support sorting based on attribute name path. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -63,6 +64,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiAxiosParamCreator = function ( labelSelector?: Array, page?: number, size?: number, + sort?: Array, options: AxiosRequestConfig = {} ): Promise => { const localVarPath = `/apis/api.console.halo.run/v1alpha1/replies`; @@ -109,6 +111,10 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiAxiosParamCreator = function ( localVarQueryParameter["size"] = size; } + if (sort) { + localVarQueryParameter["sort"] = Array.from(sort); + } + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; @@ -143,6 +149,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiFp = function ( * @param {Array} [labelSelector] Label selector for filtering. * @param {number} [page] The page number. Zero indicates no page. * @param {number} [size] Size of one page. Zero indicates no limit. + * @param {Array} [sort] Sort property and direction of the list result. Support sorting based on attribute name path. * @param {*} [options] Override http request option. * @throws {RequiredError} */ @@ -152,6 +159,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiFp = function ( labelSelector?: Array, page?: number, size?: number, + sort?: Array, options?: AxiosRequestConfig ): Promise< ( @@ -165,6 +173,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiFp = function ( labelSelector, page, size, + sort, options ); return createRequestFunction( @@ -205,6 +214,7 @@ export const ApiConsoleHaloRunV1alpha1ReplyApiFactory = function ( requestParameters.labelSelector, requestParameters.page, requestParameters.size, + requestParameters.sort, options ) .then((request) => request(axios, basePath)); @@ -252,6 +262,13 @@ export interface ApiConsoleHaloRunV1alpha1ReplyApiListRepliesRequest { * @memberof ApiConsoleHaloRunV1alpha1ReplyApiListReplies */ readonly size?: number; + + /** + * Sort property and direction of the list result. Support sorting based on attribute name path. + * @type {Array} + * @memberof ApiConsoleHaloRunV1alpha1ReplyApiListReplies + */ + readonly sort?: Array; } /** @@ -279,6 +296,7 @@ export class ApiConsoleHaloRunV1alpha1ReplyApi extends BaseAPI { requestParameters.labelSelector, requestParameters.page, requestParameters.size, + requestParameters.sort, options ) .then((request) => request(this.axios, this.basePath)); diff --git a/ui/packages/api-client/src/models/plugin-status.ts b/ui/packages/api-client/src/models/plugin-status.ts index 7fa0e4f6b8..5d7fa84729 100644 --- a/ui/packages/api-client/src/models/plugin-status.ts +++ b/ui/packages/api-client/src/models/plugin-status.ts @@ -79,6 +79,7 @@ export const PluginStatusLastProbeStateEnum = { Started: "STARTED", Stopped: "STOPPED", Failed: "FAILED", + Unloaded: "UNLOADED", } as const; export type PluginStatusLastProbeStateEnum = diff --git a/ui/packages/api-client/src/models/tag-status.ts b/ui/packages/api-client/src/models/tag-status.ts index 67af497dea..819627754e 100644 --- a/ui/packages/api-client/src/models/tag-status.ts +++ b/ui/packages/api-client/src/models/tag-status.ts @@ -18,6 +18,12 @@ * @interface TagStatus */ export interface TagStatus { + /** + * + * @type {number} + * @memberof TagStatus + */ + observedVersion?: number; /** * * @type {string}