From eaa18573f043a6acbad85c02f5a6d3b712bca7a1 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Fri, 30 Sep 2022 15:58:19 +0800 Subject: [PATCH] feat: add post and single page statistics (#2476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area core /milestone 2.0 /kind api-change #### What this PR does / why we need it: 新增文章和自定义页面统计功能: 1. 浏览量统计,通过在指定模板页面插入 tracker js 上报浏览量数据并写日志到文件同时通过 Metrics 暴露数据 2. 浏览量日志异步写文件 3. 文章列表和自定义页面列表数据增加 stats 属性 4. 主题文章列表及详情、自定义页面详情增加 stats 属性 5. 新增仪表盘统计数据展示, endpoint: `/apis/api.console.halo.run/v1alpha1/stats` ```java private Integer visits; private Integer comments; private Integer approvedComments; private Integer upvotes; private Integer users; ``` #### Which issue(s) this PR fixes: Fixes #2430 #### Special notes for your reviewer: how to test it? 1. 使用一个主题,创建几篇文章和一些自定义页面,访问他们,并在详情页面的 head 总看到一个 `halo-tracker.js` 的 script 标签 ![telegram-cloud-document-5-6167924618783885123](https://user-images.githubusercontent.com/38999863/192552428-b5635607-9810-4be3-b1fe-8a54ed3407c5.jpg) 2. 访问10次以上可以看到 halo work下有一个 `logs/visits.log`,里面有访问记录,它是10条为一批异步刷新到磁盘 3. 主题端文章列表及详情和自定义页面的详情可以取到一个stats字段 4. `/apis/api.console.halo.run/v1alpha1/stats` 此 endpoint 可以得到总的访问量和评论量,这些数据也是异步的会有一分钟延迟 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ``` --- src/main/java/run/halo/app/Application.java | 2 + .../app/config/ExtensionConfiguration.java | 6 +- .../java/run/halo/app/content/ListedPost.java | 3 + .../halo/app/content/ListedSinglePage.java | 3 + src/main/java/run/halo/app/content/Stats.java | 23 +++ .../app/content/impl/PostServiceImpl.java | 23 ++- .../content/impl/SinglePageServiceImpl.java | 24 ++- .../run/halo/app/core/extension/Counter.java | 59 ++++++ .../extension/endpoint/CommentEndpoint.java | 23 +-- .../extension/endpoint/StatsEndpoint.java | 99 ++++++++++ .../extension/endpoint/TrackerEndpoint.java | 119 ++++++++++++ .../reconciler/CommentReconciler.java | 100 +++++++++- .../run/halo/app/infra/SchemeInitializer.java | 3 + .../run/halo/app/infra/utils/HaloUtils.java | 19 ++ .../halo/app/metrics/CounterMeterHandler.java | 137 ++++++++++++++ .../run/halo/app/metrics/CounterService.java | 12 ++ .../halo/app/metrics/CounterServiceImpl.java | 44 +++++ .../java/run/halo/app/metrics/MeterUtils.java | 111 ++++++++++++ .../run/halo/app/metrics/VisitLogWriter.java | 171 ++++++++++++++++++ .../dialect/GlobalHeadInjectionProcessor.java | 2 +- .../theme/dialect/HaloTrackerProcessor.java | 63 +++++++ .../theme/endpoint/CommentFinderEndpoint.java | 6 +- .../theme/finders/impl/PostFinderImpl.java | 23 ++- .../finders/impl/SinglePageFinderImpl.java | 27 ++- .../run/halo/app/theme/finders/vo/PostVo.java | 2 + .../app/theme/finders/vo/SinglePageVo.java | 2 + .../halo/app/theme/finders/vo/StatsVo.java | 21 +++ .../router/strategy/PostRouteStrategy.java | 11 +- .../strategy/SinglePageRouteStrategy.java | 8 + .../extensions/role-template-anonymous.yaml | 5 + .../role-template-authenticated.yaml | 12 ++ src/main/resources/static/halo-tracker.js | 1 + .../app/content/impl/PostServiceImplTest.java | 8 +- .../extension/endpoint/UserEndpointTest.java | 4 + .../reconciler/CommentReconcilerTest.java | 113 +++++++++++- .../app/metrics/CounterMeterHandlerTest.java | 108 +++++++++++ .../app/metrics/CounterServiceImplTest.java | 41 +++++ .../run/halo/app/metrics/MeterUtilsTest.java | 119 ++++++++++++ .../finders/impl/PostFinderImplTest.java | 9 +- 39 files changed, 1516 insertions(+), 50 deletions(-) create mode 100644 src/main/java/run/halo/app/content/Stats.java create mode 100644 src/main/java/run/halo/app/core/extension/Counter.java create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java create mode 100644 src/main/java/run/halo/app/metrics/CounterMeterHandler.java create mode 100644 src/main/java/run/halo/app/metrics/CounterService.java create mode 100644 src/main/java/run/halo/app/metrics/CounterServiceImpl.java create mode 100644 src/main/java/run/halo/app/metrics/MeterUtils.java create mode 100644 src/main/java/run/halo/app/metrics/VisitLogWriter.java create mode 100644 src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java create mode 100644 src/main/java/run/halo/app/theme/finders/vo/StatsVo.java create mode 100644 src/main/resources/static/halo-tracker.js create mode 100644 src/test/java/run/halo/app/metrics/CounterMeterHandlerTest.java create mode 100644 src/test/java/run/halo/app/metrics/CounterServiceImplTest.java create mode 100644 src/test/java/run/halo/app/metrics/MeterUtilsTest.java diff --git a/src/main/java/run/halo/app/Application.java b/src/main/java/run/halo/app/Application.java index b3c1c8b66..04478a4e4 100644 --- a/src/main/java/run/halo/app/Application.java +++ b/src/main/java/run/halo/app/Application.java @@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.scheduling.annotation.EnableScheduling; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.infra.properties.JwtProperties; @@ -15,6 +16,7 @@ import run.halo.app.infra.properties.JwtProperties; * @author guqing * @date 2017-11-14 */ +@EnableScheduling @SpringBootApplication(scanBasePackages = "run.halo.app", exclude = IntegrationAutoConfiguration.class) @EnableConfigurationProperties({HaloProperties.class, JwtProperties.class}) diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index d7ce985da..695610cc3 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -1,5 +1,6 @@ package run.halo.app.config; +import io.micrometer.core.instrument.MeterRegistry; import org.pf4j.PluginManager; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; @@ -202,9 +203,10 @@ public class ExtensionConfiguration { } @Bean - Controller commentController(ExtensionClient client) { + Controller commentController(ExtensionClient client, MeterRegistry meterRegistry, + SchemeManager schemeManager) { return new ControllerBuilder("comment-controller", client) - .reconciler(new CommentReconciler(client)) + .reconciler(new CommentReconciler(client, meterRegistry, schemeManager)) .extension(new Comment()) .build(); } diff --git a/src/main/java/run/halo/app/content/ListedPost.java b/src/main/java/run/halo/app/content/ListedPost.java index 7c1323369..aedd10c4d 100644 --- a/src/main/java/run/halo/app/content/ListedPost.java +++ b/src/main/java/run/halo/app/content/ListedPost.java @@ -28,4 +28,7 @@ public class ListedPost { @Schema(required = true) private List contributors; + + @Schema(required = true) + private Stats stats; } diff --git a/src/main/java/run/halo/app/content/ListedSinglePage.java b/src/main/java/run/halo/app/content/ListedSinglePage.java index c80fb631e..4b3e78c12 100644 --- a/src/main/java/run/halo/app/content/ListedSinglePage.java +++ b/src/main/java/run/halo/app/content/ListedSinglePage.java @@ -20,4 +20,7 @@ public class ListedSinglePage { @Schema(required = true) private List contributors; + + @Schema(required = true) + private Stats stats; } diff --git a/src/main/java/run/halo/app/content/Stats.java b/src/main/java/run/halo/app/content/Stats.java new file mode 100644 index 000000000..6c9d07d2d --- /dev/null +++ b/src/main/java/run/halo/app/content/Stats.java @@ -0,0 +1,23 @@ +package run.halo.app.content; + +import lombok.Builder; +import lombok.Value; + +/** + * Stats value object. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class Stats { + + Integer visit; + + Integer upvote; + + Integer totalComment; + + Integer approvedComment; +} diff --git a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index 40a3e223e..ddaec9d36 100644 --- a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -24,7 +24,9 @@ import run.halo.app.content.PostQuery; import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.content.PostSorter; +import run.halo.app.content.Stats; import run.halo.app.core.extension.Category; +import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Snapshot; import run.halo.app.core.extension.Tag; @@ -33,6 +35,8 @@ import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; /** * A default implementation of {@link PostService}. @@ -44,10 +48,13 @@ import run.halo.app.infra.ConditionStatus; public class PostServiceImpl implements PostService { private final ContentService contentService; private final ReactiveExtensionClient client; + private final CounterService counterService; - public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client) { + public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client, + CounterService counterService) { this.contentService = contentService; this.client = client; + this.counterService = counterService; } @Override @@ -67,6 +74,19 @@ public class PostServiceImpl implements PostService { ); } + Stats fetchStats(Post post) { + Assert.notNull(post, "The post must not be null."); + String name = post.getMetadata().getName(); + Counter counter = + counterService.getByName(MeterUtils.nameOf(Post.class, name)); + return Stats.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .totalComment(counter.getApprovedComment()) + .approvedComment(counter.getApprovedComment()) + .build(); + } + Predicate postListPredicate(PostQuery query) { Predicate paramPredicate = post -> contains(query.getCategories(), post.getSpec().getCategories()) @@ -132,6 +152,7 @@ public class PostServiceImpl implements PostService { .map(p -> { ListedPost listedPost = new ListedPost(); listedPost.setPost(p); + listedPost.setStats(fetchStats(post)); return listedPost; }) .flatMap(lp -> setTags(post.getSpec().getTags(), lp)) diff --git a/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java index 2d98c2be0..9be30a7b3 100644 --- a/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java +++ b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java @@ -24,6 +24,8 @@ import run.halo.app.content.SinglePageQuery; import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageService; import run.halo.app.content.SinglePageSorter; +import run.halo.app.content.Stats; +import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.SinglePage; import run.halo.app.core.extension.Snapshot; @@ -32,6 +34,8 @@ import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; /** * Single page service implementation. @@ -45,9 +49,13 @@ public class SinglePageServiceImpl implements SinglePageService { private final ReactiveExtensionClient client; - public SinglePageServiceImpl(ContentService contentService, ReactiveExtensionClient client) { + private final CounterService counterService; + + public SinglePageServiceImpl(ContentService contentService, ReactiveExtensionClient client, + CounterService counterService) { this.contentService = contentService; this.client = client; + this.counterService = counterService; } @Override @@ -179,6 +187,7 @@ public class SinglePageServiceImpl implements SinglePageService { .map(sp -> { ListedSinglePage listedSinglePage = new ListedSinglePage(); listedSinglePage.setPage(singlePage); + listedSinglePage.setStats(fetchStats(singlePage)); return listedSinglePage; }) .flatMap(lsp -> @@ -194,6 +203,19 @@ public class SinglePageServiceImpl implements SinglePageService { .defaultIfEmpty(singlePage); } + Stats fetchStats(SinglePage singlePage) { + Assert.notNull(singlePage, "The singlePage must not be null."); + String name = singlePage.getMetadata().getName(); + Counter counter = + counterService.getByName(MeterUtils.nameOf(SinglePage.class, name)); + return Stats.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .totalComment(counter.getApprovedComment()) + .approvedComment(counter.getApprovedComment()) + .build(); + } + private Flux listContributors(List usernames) { if (usernames == null) { return Flux.empty(); diff --git a/src/main/java/run/halo/app/core/extension/Counter.java b/src/main/java/run/halo/app/core/extension/Counter.java new file mode 100644 index 000000000..885966867 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Counter.java @@ -0,0 +1,59 @@ +package run.halo.app.core.extension; + +import io.micrometer.core.instrument.Meter; +import java.util.Collection; +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.metrics.MeterUtils; + +/** + * A counter for number of requests by extension resource name. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@GVK(group = "metrics.halo.run", version = "v1alpha1", kind = "Counter", plural = "counters", + singular = "counter") +@EqualsAndHashCode(callSuper = true) +public class Counter extends AbstractExtension { + + private Integer visit; + + private Integer upvote; + + private Integer totalComment; + + private Integer approvedComment; + + /** + * Populate counter data from {@link Meter}s. + * + * @param meters counter meters + */ + public void populateFrom(Collection meters) { + populateDefaultValue(); + for (Meter meter : meters) { + if (meter instanceof io.micrometer.core.instrument.Counter meterCounter) { + if (MeterUtils.isVisitCounter(meterCounter)) { + this.visit = (int) meterCounter.count(); + } else if (MeterUtils.isUpvoteCounter(meterCounter)) { + this.upvote = (int) meterCounter.count(); + } else if (MeterUtils.isTotalCommentCounter(meterCounter)) { + this.totalComment = (int) meterCounter.count(); + } else if (MeterUtils.isApprovedCommentCounter(meterCounter)) { + this.approvedComment = (int) meterCounter.count(); + } + } + } + } + + private void populateDefaultValue() { + this.visit = 0; + this.upvote = 0; + this.totalComment = 0; + this.approvedComment = 0; + } +} diff --git a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java index 39bb7afb8..5c346d6b9 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/CommentEndpoint.java @@ -6,10 +6,8 @@ import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; -import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; -import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; @@ -26,6 +24,7 @@ import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Reply; import run.halo.app.extension.ListResult; import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; @@ -106,7 +105,7 @@ public class CommentEndpoint implements CustomEndpoint { .flatMap(commentRequest -> { Comment comment = commentRequest.toComment(); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); - comment.getSpec().setUserAgent(userAgentFrom(request)); + comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return commentService.create(comment); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); @@ -118,26 +117,10 @@ public class CommentEndpoint implements CustomEndpoint { .flatMap(replyRequest -> { Reply reply = replyRequest.toReply(); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); - reply.getSpec().setUserAgent(userAgentFrom(request)); + reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return replyService.create(commentName, reply); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); } - /** - * Gets user-agent from server request. - * - * @param request server request - * @return user-agent string if found, otherwise "unknown" - */ - public static String userAgentFrom(ServerRequest request) { - HttpHeaders httpHeaders = request.headers().asHttpHeaders(); - // https://en.wikipedia.org/wiki/User_agent - String userAgent = httpHeaders.getFirst(HttpHeaders.USER_AGENT); - if (StringUtils.isBlank(userAgent)) { - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-CH-UA - userAgent = httpHeaders.getFirst("Sec-CH-UA"); - } - return StringUtils.defaultString(userAgent, "unknown"); - } } diff --git a/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java new file mode 100644 index 000000000..379bef8d8 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/StatsEndpoint.java @@ -0,0 +1,99 @@ +package run.halo.app.core.extension.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; + +import lombok.Data; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ReactiveExtensionClient; + +/** + * Stats endpoint. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class StatsEndpoint implements CustomEndpoint { + + private final ReactiveExtensionClient client; + + public StatsEndpoint(ReactiveExtensionClient client) { + this.client = client; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.console.halo.run/v1alpha1/Stats"; + return SpringdocRouteBuilder.route() + .GET("stats", this::getStats, builder -> builder.operationId("getStats") + .description("Get stats.") + .tag(tag) + .response(responseBuilder() + .implementation(Stats.class) + ) + ) + .build(); + } + + Mono getStats(ServerRequest request) { + return client.list(Counter.class, null, null) + .reduce(Stats.emptyStats(), (stats, counter) -> { + stats.setVisits(stats.getVisits() + counter.getVisit()); + stats.setComments(stats.getComments() + counter.getTotalComment()); + stats.setApprovedComments( + stats.getApprovedComments() + counter.getApprovedComment()); + stats.setUpvotes(stats.getUpvotes() + counter.getUpvote()); + return stats; + }) + .flatMap(stats -> client.list(User.class, + user -> user.getMetadata().getDeletionTimestamp() == null, + null) + .count() + .map(count -> { + stats.setUsers(count.intValue()); + return stats; + })) + .flatMap(stats -> client.list(Post.class, post -> !post.isDeleted(), null) + .count() + .map(count -> { + stats.setPosts(count.intValue()); + return stats; + }) + ) + .flatMap(stats -> ServerResponse.ok().bodyValue(stats)); + } + + @Data + public static class Stats { + private Integer visits; + private Integer comments; + private Integer approvedComments; + private Integer upvotes; + private Integer users; + private Integer posts; + + /** + * Creates an empty stats that populated initialize value. + * + * @return stats with initialize value. + */ + public static Stats emptyStats() { + Stats stats = new Stats(); + stats.setVisits(0); + stats.setComments(0); + stats.setApprovedComments(0); + stats.setUpvotes(0); + stats.setUsers(0); + stats.setPosts(0); + return stats; + } + } +} diff --git a/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java new file mode 100644 index 000000000..ca4772992 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/TrackerEndpoint.java @@ -0,0 +1,119 @@ +package run.halo.app.core.extension.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import org.apache.commons.lang3.StringUtils; +import org.springdoc.core.fn.builders.schema.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; +import run.halo.app.extension.GroupVersion; +import run.halo.app.infra.utils.HaloUtils; +import run.halo.app.infra.utils.IpAddressUtils; +import run.halo.app.metrics.MeterUtils; +import run.halo.app.metrics.VisitLogWriter; + +/** + * Metrics counter endpoint. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class TrackerEndpoint implements CustomEndpoint { + + private final MeterRegistry meterRegistry; + private final VisitLogWriter visitLogWriter; + + public TrackerEndpoint(MeterRegistry meterRegistry, VisitLogWriter visitLogWriter) { + this.meterRegistry = meterRegistry; + this.visitLogWriter = visitLogWriter; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/Tracker"; + return SpringdocRouteBuilder.route() + .POST("trackers/counter", this::increase, + builder -> builder.operationId("count") + .description("Count an extension resource visits.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(CounterRequest.class)) + )) + .response(responseBuilder() + .implementation(Double.class)) + ) + .build(); + } + + private Mono increase(ServerRequest request) { + return request.bodyToMono(CounterRequest.class) + .switchIfEmpty( + Mono.error(new IllegalArgumentException("Counter request body must not be empty"))) + .map(counterRequest -> { + String counterName = + MeterUtils.nameOf(counterRequest.group(), counterRequest.plural(), + counterRequest.name()); + + Counter counter = MeterUtils.visitCounter(meterRegistry, counterName); + counter.increment(); + // async write visit log + writeVisitLog(request, counterRequest); + return (int) counter.count(); + }) + .flatMap(count -> ServerResponse.ok().bodyValue(count)); + } + + public record CounterRequest(String group, String plural, String name, String hostname, + String screen, String language, String referrer) { + /** + * Construct counter request. + * group and session uid can be empty. + */ + public CounterRequest { + Assert.notNull(plural, "The plural must not be null."); + Assert.notNull(name, "The name must not be null."); + group = StringUtils.defaultString(group); + } + } + + private void writeVisitLog(ServerRequest request, CounterRequest counterRequest) { + String logMessage = logMessage(request, counterRequest); + visitLogWriter.log(logMessage); + } + + private String logMessage(ServerRequest request, CounterRequest counterRequest) { + String ipAddress = IpAddressUtils.getIpAddress(request); + String hostname = counterRequest.hostname(); + String screen = counterRequest.screen(); + String language = counterRequest.language(); + String referrer = counterRequest.referrer(); + String userAgent = HaloUtils.userAgentFrom(request); + String counterName = + MeterUtils.nameOf(counterRequest.group(), counterRequest.plural(), + counterRequest.name()); + return String.format( + "subject=[%s], ipAddress=[%s], hostname=[%s], screen=[%s], language=[%s], " + + "referrer=[%s], userAgent=[%s]", counterName, ipAddress, hostname, screen, + language, referrer, userAgent); + } + + @Override + public GroupVersion groupVersion() { + return new GroupVersion("api.halo.run", "v1alpha1"); + } +} diff --git a/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java index dcc4b2aa0..735a128a9 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/CommentReconciler.java @@ -1,17 +1,27 @@ package run.halo.app.core.extension.reconciler; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; import java.time.Duration; import java.time.Instant; import java.util.Comparator; import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.lang.Nullable; import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Reply; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.extension.Ref; +import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.metrics.MeterUtils; /** * Reconciler for {@link Comment}. @@ -22,9 +32,14 @@ import run.halo.app.infra.utils.JsonUtils; public class CommentReconciler implements Reconciler { public static final String FINALIZER_NAME = "comment-protection"; private final ExtensionClient client; + private final MeterRegistry meterRegistry; + private final SchemeManager schemeManager; - public CommentReconciler(ExtensionClient client) { + public CommentReconciler(ExtensionClient client, MeterRegistry meterRegistry, + SchemeManager schemeManager) { this.client = client; + this.meterRegistry = meterRegistry; + this.schemeManager = schemeManager; } @Override @@ -37,6 +52,7 @@ public class CommentReconciler implements Reconciler { } addFinalizerIfNecessary(comment); reconcileStatus(request.name()); + reconcileCommentCount(); return new Result(true, Duration.ofMinutes(1)); }) .orElseGet(() -> new Result(false, null)); @@ -95,6 +111,67 @@ public class CommentReconciler implements Reconciler { }); } + private void reconcileCommentCount() { + Map> map = client.list(Comment.class, null, null) + .stream() + .map(comment -> { + boolean approved = + Objects.equals(true, comment.getSpec().getApproved()) + && !isDeleted(comment); + return new RefCommentTuple(comment.getSpec().getSubjectRef(), + comment.getMetadata().getName(), approved); + }) + .collect(Collectors.groupingBy(RefCommentTuple::ref)); + map.forEach((ref, pairs) -> { + GroupVersionKind groupVersionKind = groupVersionKind(ref); + if (groupVersionKind == null) { + return; + } + // approved total count + long approvedTotalCount = pairs.stream() + .filter(refCommentPair -> refCommentPair.approved) + .count(); + // total count + int totalCount = pairs.size(); + + schemeManager.fetch(groupVersionKind).ifPresent(scheme -> { + String counterName = MeterUtils.nameOf(ref.getGroup(), scheme.plural(), + ref.getName()); + // meter for total comment count + calcTotalComments(totalCount, counterName); + // meter for approved comment count + calcApprovedComments(approvedTotalCount, counterName); + }); + }); + } + + private void calcTotalComments(int totalCount, String counterName) { + Counter totalCommentCounter = + MeterUtils.totalCommentCounter(meterRegistry, counterName); + double totalCountMeter = totalCommentCounter.count(); + double totalIncrement = totalCount - totalCountMeter; + if (totalCountMeter + totalIncrement >= 0) { + totalCommentCounter.increment(totalIncrement); + } else { + totalCommentCounter.increment(totalCountMeter * -1); + } + } + + private void calcApprovedComments(long approvedTotalCount, String counterName) { + Counter approvedCommentCounter = + MeterUtils.approvedCommentCounter(meterRegistry, counterName); + double approvedComments = approvedCommentCounter.count(); + double increment = approvedTotalCount - approvedCommentCounter.count(); + if (approvedComments + increment >= 0) { + approvedCommentCounter.increment(increment); + } else { + approvedCommentCounter.increment(approvedComments * -1); + } + } + + record RefCommentTuple(Ref ref, String name, boolean approved) { + } + private void cleanUpResourcesAndRemoveFinalizer(String commentName) { client.fetch(Comment.class, commentName).ifPresent(comment -> { cleanUpResources(comment); @@ -111,6 +188,27 @@ public class CommentReconciler implements Reconciler { .equals(reply.getSpec().getCommentName()), null) .forEach(client::delete); + // decrement total comment count + Ref subjectRef = comment.getSpec().getSubjectRef(); + GroupVersionKind groupVersionKind = groupVersionKind(subjectRef); + if (groupVersionKind == null) { + return; + } + schemeManager.fetch(groupVersionKind) + .ifPresent(scheme -> { + String counterName = MeterUtils.nameOf(subjectRef.getGroup(), scheme.plural(), + subjectRef.getName()); + MeterUtils.totalCommentCounter(meterRegistry, counterName) + .increment(-1); + }); + } + + @Nullable + private GroupVersionKind groupVersionKind(Ref ref) { + if (ref == null) { + return null; + } + return new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind()); } Comparator defaultReplyComparator() { diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index bbf72bf8e..8f1d60526 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -6,6 +6,7 @@ import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import run.halo.app.core.extension.Category; import run.halo.app.core.extension.Comment; +import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.Plugin; @@ -62,5 +63,7 @@ public class SchemeInitializer implements ApplicationListener onApplicationReady(ApplicationReadyEvent event) { + return client.list(Counter.class, null, null) + .map(counter -> { + String name = counter.getMetadata().getName(); + // visit counter + io.micrometer.core.instrument.Counter visitCounter = + MeterUtils.visitCounter(meterRegistry, name); + visitCounter.increment(nullSafe(counter.getVisit())); + + // upvote counter + io.micrometer.core.instrument.Counter upvoteCounter = + MeterUtils.upvoteCounter(meterRegistry, name); + upvoteCounter.increment(nullSafe(counter.getUpvote())); + + // total comment counter + io.micrometer.core.instrument.Counter totalCommentCounter = + MeterUtils.totalCommentCounter(meterRegistry, name); + totalCommentCounter.increment(nullSafe(counter.getTotalComment())); + + // approved comment counter + io.micrometer.core.instrument.Counter approvedCommentCounter = + MeterUtils.approvedCommentCounter(meterRegistry, name); + approvedCommentCounter.increment(nullSafe(counter.getApprovedComment())); + return counter; + }) + .then(); + } + + int nullSafe(Integer value) { + return Objects.requireNonNullElse(value, 0); + } + + /** + * Synchronize memory counter meter to the database every minute. + */ + @Scheduled(cron = "0 0/1 * * * ?") + public void counterPersistenceTask() { + log.debug("Regularly synchronize counter meters to the database."); + save().block(); + } + + Mono save() { + Map> nameMeters = meterRegistry.getMeters().stream() + .filter(meter -> meter instanceof io.micrometer.core.instrument.Counter) + .filter(counter -> { + Meter.Id id = counter.getId(); + return id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey()) != null; + }) + .collect(Collectors.groupingBy(meter -> meter.getId().getName())); + Stream> monoStream = nameMeters.entrySet().stream() + .map(entry -> { + String name = entry.getKey(); + List meters = entry.getValue(); + return client.fetch(Counter.class, name) + .switchIfEmpty(Mono.defer(() -> { + Counter counter = emptyCounter(name); + return client.create(counter); + })) + .flatMap(counter -> { + Counter oldCounter = JsonUtils.deepCopy(counter); + counter.populateFrom(meters); + if (oldCounter.equals(counter)) { + return Mono.empty(); + } + return Mono.just(counter); + }) + .flatMap(client::update); + }); + return Flux.fromStream(monoStream) + .flatMap(Function.identity()) + .then(); + } + + static Counter emptyCounter(String name) { + Counter counter = new Counter(); + counter.setMetadata(new Metadata()); + counter.getMetadata().setName(name); + counter.setUpvote(0); + counter.setTotalComment(0); + counter.setApprovedComment(0); + counter.setVisit(0); + return counter; + } + + @Override + public void destroy() { + log.debug("Persist counter meters to database before destroy..."); + save().block(); + } +} diff --git a/src/main/java/run/halo/app/metrics/CounterService.java b/src/main/java/run/halo/app/metrics/CounterService.java new file mode 100644 index 000000000..f1fb9c12c --- /dev/null +++ b/src/main/java/run/halo/app/metrics/CounterService.java @@ -0,0 +1,12 @@ +package run.halo.app.metrics; + +import run.halo.app.core.extension.Counter; + +/** + * @author guqing + * @since 2.0.0 + */ +public interface CounterService { + + Counter getByName(String counterName); +} diff --git a/src/main/java/run/halo/app/metrics/CounterServiceImpl.java b/src/main/java/run/halo/app/metrics/CounterServiceImpl.java new file mode 100644 index 000000000..d9d44a295 --- /dev/null +++ b/src/main/java/run/halo/app/metrics/CounterServiceImpl.java @@ -0,0 +1,44 @@ +package run.halo.app.metrics; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import java.util.Collection; +import org.springframework.stereotype.Service; +import run.halo.app.core.extension.Counter; +import run.halo.app.extension.Metadata; + +/** + * Counter service implementation. + * + * @author guqing + * @since 2.0.0 + */ +@Service +public class CounterServiceImpl implements CounterService { + + private final MeterRegistry meterRegistry; + + public CounterServiceImpl(MeterRegistry meterRegistry) { + this.meterRegistry = meterRegistry; + } + + @Override + public Counter getByName(String counterName) { + Tag commonTag = MeterUtils.METRICS_COMMON_TAG; + Collection counters = meterRegistry.find(counterName) + .tag(commonTag.getKey(), + valueMatch -> commonTag.getValue().equals(valueMatch)) + .counters(); + + Counter counter = emptyCounter(counterName); + counter.populateFrom(counters); + return counter; + } + + private Counter emptyCounter(String name) { + Counter counter = new Counter(); + counter.setMetadata(new Metadata()); + counter.getMetadata().setName(name); + return counter; + } +} diff --git a/src/main/java/run/halo/app/metrics/MeterUtils.java b/src/main/java/run/halo/app/metrics/MeterUtils.java new file mode 100644 index 000000000..64a259dde --- /dev/null +++ b/src/main/java/run/halo/app/metrics/MeterUtils.java @@ -0,0 +1,111 @@ +package run.halo.app.metrics; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tag; +import io.micrometer.core.instrument.Tags; +import org.apache.commons.lang3.StringUtils; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * Meter utils. + * + * @author guqing + * @since 2.0.0 + */ +public class MeterUtils { + + public static final Tag METRICS_COMMON_TAG = Tag.of("metrics.halo.run", "true"); + public static final String SCENE = "scene"; + public static final String VISIT_SCENE = "visit"; + public static final String UPVOTE_SCENE = "upvote"; + public static final String TOTAL_COMMENT_SCENE = "total_comment"; + public static final String APPROVED_COMMENT_SCENE = "approved_comment"; + + /** + * Build a counter name. + * + * @param group extension group + * @param plural extension plural + * @param name extension name + * @return counter name + */ + public static String nameOf(String group, String plural, String name) { + if (StringUtils.isBlank(group)) { + return String.join("/", plural, name); + } + return String.join(".", plural, group) + "/" + name; + } + + public static String nameOf(Class clazz, String name) { + GVK annotation = clazz.getAnnotation(GVK.class); + return nameOf(annotation.group(), annotation.plural(), name); + } + + public static Counter visitCounter(MeterRegistry registry, String name) { + return counter(registry, name, Tag.of(SCENE, VISIT_SCENE)); + } + + public static Counter upvoteCounter(MeterRegistry registry, String name) { + return counter(registry, name, Tag.of(SCENE, UPVOTE_SCENE)); + } + + public static Counter totalCommentCounter(MeterRegistry registry, String name) { + return counter(registry, name, Tag.of(SCENE, TOTAL_COMMENT_SCENE)); + } + + public static Counter approvedCommentCounter(MeterRegistry registry, String name) { + return counter(registry, name, Tag.of(SCENE, APPROVED_COMMENT_SCENE)); + } + + public static boolean isVisitCounter(Counter counter) { + String sceneValue = counter.getId().getTag(SCENE); + if (StringUtils.isBlank(sceneValue)) { + return false; + } + return VISIT_SCENE.equals(sceneValue); + } + + public static boolean isUpvoteCounter(Counter counter) { + String sceneValue = counter.getId().getTag(SCENE); + if (StringUtils.isBlank(sceneValue)) { + return false; + } + return UPVOTE_SCENE.equals(sceneValue); + } + + public static boolean isTotalCommentCounter(Counter counter) { + String sceneValue = counter.getId().getTag(SCENE); + if (StringUtils.isBlank(sceneValue)) { + return false; + } + return TOTAL_COMMENT_SCENE.equals(sceneValue); + } + + public static boolean isApprovedCommentCounter(Counter counter) { + String sceneValue = counter.getId().getTag(SCENE); + if (StringUtils.isBlank(sceneValue)) { + return false; + } + return APPROVED_COMMENT_SCENE.equals(sceneValue); + } + + /** + * Build a {@link Counter} for halo extension. + * + * @param registry meter registry + * @param name counter name,build by {@link #nameOf(String, String, String)} + * @return counter find by name from registry if exists, otherwise create a new one. + */ + private static Counter counter(MeterRegistry registry, String name, Tag... tags) { + Tags withTags = Tags.of(METRICS_COMMON_TAG).and(tags); + Counter counter = registry.find(name) + .tags(withTags) + .counter(); + if (counter == null) { + return registry.counter(name, withTags); + } + return counter; + } +} diff --git a/src/main/java/run/halo/app/metrics/VisitLogWriter.java b/src/main/java/run/halo/app/metrics/VisitLogWriter.java new file mode 100644 index 000000000..f25ec8455 --- /dev/null +++ b/src/main/java/run/halo/app/metrics/VisitLogWriter.java @@ -0,0 +1,171 @@ +package run.halo.app.metrics; + +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.stereotype.Component; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.FileUtils; + +/** + * Visit log writer. + * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +@Component +public class VisitLogWriter implements InitializingBean, DisposableBean { + private static final String LOG_FILE_NAME = "visits.log"; + private static final String LOG_FILE_LOCATION = "logs"; + private final AsyncLogWriter asyncLogWriter; + private volatile boolean interruptThread = false; + + private final Path logFilePath; + + private VisitLogWriter(HaloProperties haloProperties) throws IOException { + Path logsPath = haloProperties.getWorkDir() + .resolve(LOG_FILE_LOCATION); + if (!Files.exists(logsPath)) { + Files.createDirectories(logsPath); + } + this.logFilePath = logsPath.resolve(LOG_FILE_NAME); + this.asyncLogWriter = new AsyncLogWriter(logFilePath); + } + + public synchronized void log(String logMsg) { + asyncLogWriter.put(logMsg); + } + + public Path getLogFilePath() { + return logFilePath; + } + + void start() { + log.debug("Starting write visit log..."); + Thread thread = new Thread(() -> { + while (!interruptThread) { + asyncLogWriter.writeLog(); + } + }, "visits-log-writer"); + thread.start(); + } + + @Override + public void afterPropertiesSet() throws Exception { + start(); + } + + @Override + public void destroy() throws Exception { + asyncLogWriter.close(); + interruptThread = true; + } + + static class AsyncLogWriter { + private static final int MAX_LOG_SIZE = 10000; + private static final int BATCH_SIZE = 10; + private final ReentrantLock lock = new ReentrantLock(); + private final Condition fullCondition = lock.newCondition(); + private final Condition emptyCondition = lock.newCondition(); + private final BufferedOutputStream writer; + private final Deque logQueue; + private final AtomicInteger logBatch = new AtomicInteger(0); + + public AsyncLogWriter(Path logFilePath) { + OutputStream outputStream; + try { + outputStream = Files.newOutputStream(logFilePath, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + } catch (IOException e) { + throw new RuntimeException(e); + } + this.writer = new BufferedOutputStream(outputStream); + this.logQueue = new ArrayDeque<>(); + } + + public void writeLog() { + lock.lock(); + try { + // queue is empty, wait for new log + while (logQueue.isEmpty()) { + try { + emptyCondition.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + String logMessage = logQueue.poll(); + writeToDisk(logMessage); + log.debug("Consumption visit log message: [{}]", logMessage); + // signal log producer + fullCondition.signal(); + } finally { + lock.unlock(); + } + } + + void writeToDisk(String logMsg) { + String format = String.format("%s %s\n", Instant.now(), logMsg); + lock.lock(); + try { + writer.write(format.getBytes(), 0, format.length()); + int size = logBatch.incrementAndGet(); + if (size >= BATCH_SIZE) { + writer.flush(); + logBatch.set(0); + } + } catch (IOException e) { + log.warn("Record access log failure: ", ExceptionUtils.getRootCause(e)); + } finally { + lock.unlock(); + } + } + + public void put(String logMessage) { + lock.lock(); + try { + while (logQueue.size() == MAX_LOG_SIZE) { + try { + log.debug("Queue full, producer thread waiting..."); + fullCondition.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + // add log message to queue tail + logQueue.add(logMessage); + log.info("Production a log messages [{}]", logMessage); + // signal consumer thread + emptyCondition.signal(); + } finally { + lock.unlock(); + } + } + + public void close() { + if (writer != null) { + try { + writer.flush(); + } catch (IOException e) { + // ignore this + } + FileUtils.closeQuietly(writer); + } + } + } +} diff --git a/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java b/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java index c4c3929e0..2b6e16427 100644 --- a/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java +++ b/src/main/java/run/halo/app/theme/dialect/GlobalHeadInjectionProcessor.java @@ -68,7 +68,7 @@ public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor getTemplateHeadProcessors(appCtx); for (TemplateHeadProcessor processor : templateHeadProcessors) { processor.process(context, modelToInsert, structureHandler) - .subscribe(); + .block(); } // add to target model diff --git a/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java b/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java new file mode 100644 index 000000000..1e820e047 --- /dev/null +++ b/src/main/java/run/halo/app/theme/dialect/HaloTrackerProcessor.java @@ -0,0 +1,63 @@ +package run.halo.app.theme.dialect; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.ITemplateContext; +import org.thymeleaf.model.IModel; +import org.thymeleaf.model.IModelFactory; +import org.thymeleaf.processor.element.IElementModelStructureHandler; +import reactor.core.publisher.Mono; +import run.halo.app.extension.GroupVersionKind; +import run.halo.app.infra.properties.HaloProperties; +import run.halo.app.infra.utils.PathUtils; + +/** + * Get {@link GroupVersionKind} and {@code plural} from the view model to construct tracker + * script tag and insert it into the head tag. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class HaloTrackerProcessor implements TemplateHeadProcessor { + + private final HaloProperties haloProperties; + + public HaloTrackerProcessor(HaloProperties haloProperties) { + this.haloProperties = haloProperties; + } + + @Override + public Mono process(ITemplateContext context, IModel model, + IElementModelStructureHandler structureHandler) { + final IModelFactory modelFactory = context.getModelFactory(); + return Mono.just(getTrackerScript(context)) + .filter(StringUtils::isNotBlank) + .map(trackerScript -> { + model.add(modelFactory.createText(trackerScript)); + return trackerScript; + }) + .then(); + } + + private String getTrackerScript(ITemplateContext context) { + String resourceName = (String) context.getVariable("name"); + String externalUrl = haloProperties.getExternalUrl().getPath(); + Object groupVersionKind = context.getVariable("groupVersionKind"); + Object plural = context.getVariable("plural"); + if (groupVersionKind == null || plural == null) { + return StringUtils.EMPTY; + } + if (!(groupVersionKind instanceof GroupVersionKind gvk)) { + return StringUtils.EMPTY; + } + return trackerScript(externalUrl, gvk.group(), (String) plural, resourceName); + } + + private String trackerScript(String externalUrl, String group, String plural, String name) { + String jsSrc = PathUtils.combinePath(externalUrl, "/halo-tracker.js"); + return """ + + """.formatted(jsSrc, group, plural, name); + } +} diff --git a/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java index b1f953c48..ceddd0d65 100644 --- a/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java +++ b/src/main/java/run/halo/app/theme/endpoint/CommentFinderEndpoint.java @@ -26,13 +26,13 @@ import run.halo.app.content.comment.ReplyRequest; import run.halo.app.content.comment.ReplyService; import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Reply; -import run.halo.app.core.extension.endpoint.CommentEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.extension.GroupVersion; import run.halo.app.extension.ListResult; import run.halo.app.extension.Ref; import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.QueryParamBuildUtil; +import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.vo.CommentVo; @@ -145,7 +145,7 @@ public class CommentFinderEndpoint implements CustomEndpoint { .flatMap(commentRequest -> { Comment comment = commentRequest.toComment(); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); - comment.getSpec().setUserAgent(CommentEndpoint.userAgentFrom(request)); + comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return commentService.create(comment); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); @@ -157,7 +157,7 @@ public class CommentFinderEndpoint implements CustomEndpoint { .flatMap(replyRequest -> { Reply reply = replyRequest.toReply(); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); - reply.getSpec().setUserAgent(CommentEndpoint.userAgentFrom(request)); + reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request)); return replyService.create(commentName, reply); }) .flatMap(comment -> ServerResponse.ok().bodyValue(comment)); diff --git a/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java index 5ce1b7ec7..ff4cd13e1 100644 --- a/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java +++ b/src/main/java/run/halo/app/theme/finders/impl/PostFinderImpl.java @@ -10,9 +10,12 @@ import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import run.halo.app.content.ContentService; +import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; import run.halo.app.theme.finders.CategoryFinder; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.Finder; @@ -22,6 +25,7 @@ import run.halo.app.theme.finders.vo.CategoryVo; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.Contributor; import run.halo.app.theme.finders.vo.PostVo; +import run.halo.app.theme.finders.vo.StatsVo; import run.halo.app.theme.finders.vo.TagVo; /** @@ -47,16 +51,19 @@ public class PostFinderImpl implements PostFinder { private final ContributorFinder contributorFinder; + private final CounterService counterService; + public PostFinderImpl(ReactiveExtensionClient client, ContentService contentService, TagFinder tagFinder, CategoryFinder categoryFinder, - ContributorFinder contributorFinder) { + ContributorFinder contributorFinder, CounterService counterService) { this.client = client; this.contentService = contentService; this.tagFinder = tagFinder; this.categoryFinder = categoryFinder; this.contributorFinder = contributorFinder; + this.counterService = counterService; } @Override @@ -116,10 +123,23 @@ public class PostFinderImpl implements PostFinder { } List postVos = list.get() .map(this::getPostVo) + .peek(this::populateStats) .toList(); return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos); } + private void populateStats(PostVo postVo) { + Counter counter = + counterService.getByName(MeterUtils.nameOf(Post.class, postVo.getMetadata() + .getName())); + StatsVo statsVo = StatsVo.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .comment(counter.getApprovedComment()) + .build(); + postVo.setStats(statsVo); + } + private PostVo getPostVo(@NonNull Post post) { List tags = tagFinder.getByNames(post.getSpec().getTags()); List categoryVos = categoryFinder.getByNames(post.getSpec().getCategories()); @@ -129,6 +149,7 @@ public class PostFinderImpl implements PostFinder { postVo.setCategories(categoryVos); postVo.setTags(tags); postVo.setContributors(contributors); + populateStats(postVo); return postVo; } diff --git a/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java b/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java index 315efef03..c7fcbb37e 100644 --- a/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java +++ b/src/main/java/run/halo/app/theme/finders/impl/SinglePageFinderImpl.java @@ -8,16 +8,20 @@ import java.util.function.Function; import java.util.function.Predicate; import org.apache.commons.lang3.ObjectUtils; import run.halo.app.content.ContentService; +import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.SinglePage; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.metrics.CounterService; +import run.halo.app.metrics.MeterUtils; import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.Contributor; import run.halo.app.theme.finders.vo.SinglePageVo; +import run.halo.app.theme.finders.vo.StatsVo; /** * A default implementation of {@link SinglePage}. @@ -39,11 +43,14 @@ public class SinglePageFinderImpl implements SinglePageFinder { private final ContributorFinder contributorFinder; + private final CounterService counterService; + public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService, - ContributorFinder contributorFinder) { + ContributorFinder contributorFinder, CounterService counterService) { this.client = client; this.contentService = contentService; this.contributorFinder = contributorFinder; + this.counterService = counterService; } @Override @@ -58,6 +65,7 @@ public class SinglePageFinderImpl implements SinglePageFinder { SinglePageVo pageVo = SinglePageVo.from(page); pageVo.setContributors(contributors); pageVo.setContent(content(pageName)); + populateStats(pageVo); return pageVo; } @@ -79,16 +87,29 @@ public class SinglePageFinderImpl implements SinglePageFinder { if (list == null) { return new ListResult<>(0, 0, 0, List.of()); } - List postVos = list.get() + List pageVos = list.get() .map(sp -> { List contributors = contributorFinder.getContributors(sp.getStatus().getContributors()); SinglePageVo pageVo = SinglePageVo.from(sp); pageVo.setContributors(contributors); + populateStats(pageVo); return pageVo; }) .toList(); - return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos); + return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), pageVos); + } + + void populateStats(SinglePageVo pageVo) { + String name = pageVo.getMetadata().getName(); + Counter counter = + counterService.getByName(MeterUtils.nameOf(SinglePage.class, name)); + StatsVo statsVo = StatsVo.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .comment(counter.getApprovedComment()) + .build(); + pageVo.setStats(statsVo); } static Comparator defaultComparator() { diff --git a/src/main/java/run/halo/app/theme/finders/vo/PostVo.java b/src/main/java/run/halo/app/theme/finders/vo/PostVo.java index 818014757..2394a5fd0 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/PostVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/PostVo.java @@ -35,6 +35,8 @@ public class PostVo { private List contributors; + private StatsVo stats; + /** * Convert {@link Post} to {@link PostVo}. * diff --git a/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java b/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java index 2632246c2..658fc8463 100644 --- a/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java +++ b/src/main/java/run/halo/app/theme/finders/vo/SinglePageVo.java @@ -29,6 +29,8 @@ public class SinglePageVo { private ContentVo content; + private StatsVo stats; + private List contributors; /** diff --git a/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java b/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java new file mode 100644 index 000000000..35025b69a --- /dev/null +++ b/src/main/java/run/halo/app/theme/finders/vo/StatsVo.java @@ -0,0 +1,21 @@ +package run.halo.app.theme.finders.vo; + +import lombok.Builder; +import lombok.Value; + +/** + * Stats value object. + * + * @author guqing + * @since 2.0.0 + */ +@Value +@Builder +public class StatsVo { + + Integer visit; + + Integer upvote; + + Integer comment; +} diff --git a/src/main/java/run/halo/app/theme/router/strategy/PostRouteStrategy.java b/src/main/java/run/halo/app/theme/router/strategy/PostRouteStrategy.java index 7d11ec7c4..36592b4f9 100644 --- a/src/main/java/run/halo/app/theme/router/strategy/PostRouteStrategy.java +++ b/src/main/java/run/halo/app/theme/router/strategy/PostRouteStrategy.java @@ -21,6 +21,7 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.content.permalinks.ExtensionLocator; import run.halo.app.core.extension.Post; +import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.PostFinder; @@ -49,7 +50,7 @@ public class PostRouteStrategy implements TemplateRouterStrategy { public RouterFunction getRouteFunction(String template, String pattern) { PostRequestParamPredicate postParamPredicate = new PostRequestParamPredicate(pattern); - + GVK gvk = Post.class.getAnnotation(GVK.class); if (postParamPredicate.isQueryParamPattern()) { String paramName = postParamPredicate.getParamName(); String placeholderName = postParamPredicate.getPlaceholderName(); @@ -72,10 +73,14 @@ public class PostRouteStrategy implements TemplateRouterStrategy { if (name == null) { return ServerResponse.notFound().build(); } + GroupVersionKind groupVersionKind = + new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()); return ServerResponse.ok() .render(DefaultTemplateEnum.POST.getValue(), Map.of(PostRequestParamPredicate.NAME_PARAM, name, - "post", postByName(name)) + "post", postByName(name), + "groupVersionKind", groupVersionKind, + "plural", gvk.plural()) ); }); } @@ -95,6 +100,8 @@ public class PostRouteStrategy implements TemplateRouterStrategy { model.putAll(pathMatchInfo.getUriVariables()); } model.put("post", postByName(locator.name())); + model.put("groupVersionKind", locator.gvk()); + model.put("plural", gvk.plural()); return ServerResponse.ok() .render(DefaultTemplateEnum.POST.getValue(), model); }); diff --git a/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java b/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java index b0dd5376e..5d5b809b7 100644 --- a/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java +++ b/src/main/java/run/halo/app/theme/router/strategy/SinglePageRouteStrategy.java @@ -14,6 +14,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.GVK; import run.halo.app.extension.GroupVersionKind; import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.finders.SinglePageFinder; @@ -60,10 +61,17 @@ public class SinglePageRouteStrategy implements TemplateRouterStrategy { return ServerResponse.ok() .render(DefaultTemplateEnum.SINGLE_PAGE.getValue(), Map.of("name", name, + "groupVersionKind", gvk, + "plural", getPlural(), "singlePage", singlePageByName(name))); }); } + private String getPlural() { + GVK annotation = SinglePage.class.getAnnotation(GVK.class); + return annotation.plural(); + } + private Mono singlePageByName(String name) { return Mono.defer(() -> Mono.just(singlePageFinder.getByName(name))) .publishOn(Schedulers.boundedElastic()); diff --git a/src/main/resources/extensions/role-template-anonymous.yaml b/src/main/resources/extensions/role-template-anonymous.yaml index e83eb249c..375c9a1ed 100644 --- a/src/main/resources/extensions/role-template-anonymous.yaml +++ b/src/main/resources/extensions/role-template-anonymous.yaml @@ -9,3 +9,8 @@ rules: - apiGroups: [ "api.halo.run" ] resources: [ "comments", "comments/reply" ] verbs: [ "create", "get", "list" ] + - apiGroups: [ "api.halo.run" ] + resources: [ "*" ] + verbs: [ "*" ] + - nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/counter" ] + verbs: [ "post" ] diff --git a/src/main/resources/extensions/role-template-authenticated.yaml b/src/main/resources/extensions/role-template-authenticated.yaml index 8cc6f160b..7045be170 100644 --- a/src/main/resources/extensions/role-template-authenticated.yaml +++ b/src/main/resources/extensions/role-template-authenticated.yaml @@ -49,3 +49,15 @@ rules: resources: [ "users/password" ] resourceNames: [ "-" ] verbs: [ "update" ] +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-stats + labels: + halo.run/role-template: "true" + halo.run/hidden: "true" +rules: + - apiGroups: [ "api.console.halo.run" ] + resources: [ "stats" ] + verbs: [ "get", "list" ] \ No newline at end of file diff --git a/src/main/resources/static/halo-tracker.js b/src/main/resources/static/halo-tracker.js new file mode 100644 index 000000000..c919bb01d --- /dev/null +++ b/src/main/resources/static/halo-tracker.js @@ -0,0 +1 @@ +!function(){"use strict";!function(t){var e=t.screen,r=e.width,n=e.height,a=t.navigator.language,o=t.location,i=t.localStorage,c=t.document,u=t.history,l=o.hostname,s=o.pathname,p=o.search,f=c.currentScript;if(f){var h=function(t,e,r){var n=t[e];return function(){for(var e=[],a=arguments.length;a--;)e[a]=arguments[a];return r.apply(null,e),n.apply(t,e)}},d=function(){return i&&i.getItem("haloTracker.disabled")||T&&function(){var e=t.doNotTrack,r=t.navigator,n=t.external,a="msTrackingProtectionEnabled",o=e||r.doNotTrack||r.msDoNotTrack||n&&a in n&&n[a]();return"1"==o||"yes"===o}()||j&&!w.includes(l)},g="data-",v=f.getAttribute.bind(f),m=v(g+"group")||"",k=v(g+"plural"),y=v(g+"name"),S=v(g+"host-url"),b="false"!==v(g+"auto-track"),T=v(g+"do-not-track"),j=v(g+"domains")||"",w=j.split(",").map((function(t){return t.trim()})),E=(S?S.replace(/\/$/,""):f.src.split("/").slice(0,-1).join("/"))+"/apis/api.halo.run/v1alpha1/trackers/counter",N=r+"x"+n,O=""+s+p,x=c.referrer,P=function(t,e){return void 0===t&&(t=O),void 0===e&&(e=x),function(t){if(!d())return fetch(E,{method:"POST",body:JSON.stringify(Object.assign({},t)),headers:{"Content-Type":"application/json"}}).then((function(t){return t.text()})).then((function(t){console.debug("Visit count:",t)}))}((r={group:m,plural:k,name:y,hostname:l,screen:N,language:a,url:O},n={url:t,referrer:e},Object.keys(n).forEach((function(t){void 0!==n[t]&&(r[t]=n[t])})),r));var r,n},V=function(t,e,r){if(r){x=O;var n=r.toString();(O="http"===n.substring(0,4)?"/"+n.split("/").splice(3).join("/"):n)!==x&&P()}};if(!t.haloTracker){var A=function(t){return trackEvent(t)};A.trackView=P,t.haloTracker=A}if(b&&!d()){u.pushState=h(u,"pushState",V),u.replaceState=h(u,"replaceState",V);var C=function(){"complete"===c.readyState&&P()};c.addEventListener("readystatechange",C,!0),C()}}}(window)}(); diff --git a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java index a3ce6b42f..32d2e8ac6 100644 --- a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java +++ b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java @@ -5,9 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.List; import java.util.Map; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.util.LinkedMultiValueMap; @@ -32,13 +32,9 @@ class PostServiceImplTest { @Mock private ContentService contentService; + @InjectMocks private PostServiceImpl postService; - @BeforeEach - void setUp() { - postService = new PostServiceImpl(contentService, client); - } - @Test void listPredicate() { MultiValueMap multiValueMap = new LinkedMultiValueMap<>(); diff --git a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index cd71970a0..719bc67dd 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -38,6 +38,7 @@ import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.metrics.CounterMeterHandler; @SpringBootTest @AutoConfigureWebTestClient @@ -56,6 +57,9 @@ class UserEndpointTest { @MockBean UserService userService; + @MockBean + CounterMeterHandler counterMeterHandler; + @MockBean SystemConfigurableEnvironmentFetcher environmentFetcher; diff --git a/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java index 751b17ba8..3ded07c63 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/CommentReconcilerTest.java @@ -6,24 +6,37 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import run.halo.app.core.extension.Comment; +import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Reply; import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.Metadata; +import run.halo.app.extension.Ref; +import run.halo.app.extension.Scheme; +import run.halo.app.extension.SchemeManager; import run.halo.app.extension.controller.Reconciler; +import run.halo.app.metrics.MeterUtils; /** * Tests for {@link CommentReconciler}. @@ -37,11 +50,19 @@ class CommentReconcilerTest { @Mock private ExtensionClient client; - @InjectMocks + private final MeterRegistry meterRegistry = new SimpleMeterRegistry(); + @Mock + SchemeManager schemeManager; + private CommentReconciler commentReconciler; private final Instant now = Instant.now(); + @BeforeEach + void setUp() { + commentReconciler = new CommentReconciler(client, meterRegistry, schemeManager); + } + @Test void reconcile() { Comment comment = new Comment(); @@ -112,6 +133,94 @@ class CommentReconcilerTest { .contains(CommentReconciler.FINALIZER_NAME)).isFalse(); } + @Test + void reconcileCommentCount() { + when(client.list(eq(Comment.class), any(), any())) + .thenReturn(commentList()); + when(client.fetch(eq(Comment.class), eq("test"))) + .thenReturn(Optional.of(getComment("test"))); + lenient().when(client.list(eq(Reply.class), any(), any())) + .thenReturn(List.of()); + + Ref ref = getRef(); + GroupVersionKind groupVersionKind = + new GroupVersionKind(ref.getGroup(), ref.getVersion(), ref.getKind()); + Scheme scheme = new Scheme(Post.class, groupVersionKind, "posts", "post", + Mockito.mock(ObjectNode.class)); + when(schemeManager.fetch(any())).thenReturn(Optional.of(scheme)); + + String fakePostCounterName = + MeterUtils.nameOf(ref.getGroup(), scheme.plural(), "fake-post"); + String testPostCounterName = + MeterUtils.nameOf(ref.getGroup(), scheme.plural(), "test-post"); + + Counter approvedCommentCounter = + MeterUtils.approvedCommentCounter(meterRegistry, fakePostCounterName); + approvedCommentCounter.increment(5); + + assertThat(approvedCommentCounter.count()).isEqualTo(5.0); + + Counter testPostCounter = + MeterUtils.approvedCommentCounter(meterRegistry, testPostCounterName); + testPostCounter.increment(0); + assertThat(testPostCounter.count()).isEqualTo(0.0); + + Counter totalCommentCounter = + MeterUtils.totalCommentCounter(meterRegistry, fakePostCounterName); + totalCommentCounter.increment(8); + + commentReconciler.reconcile(new Reconciler.Request("test")); + assertThat(approvedCommentCounter.count()).isEqualTo(3.0); + assertThat(testPostCounter.count()).isEqualTo(1.0); + assertThat(totalCommentCounter.count()).isEqualTo(5.0); + } + + List commentList() { + final List comments = new ArrayList<>(); + final Comment commentA = getComment("A"); + final Comment commentB = getComment("B"); + final Comment commentC = getComment("C"); + + Comment commentD = getComment("D"); + commentD.getSpec().getSubjectRef().setName("test-post"); + + final Comment commentE = getComment("E"); + commentE.getSpec().setApproved(false); + + final Comment commentF = getComment("F"); + commentF.getSpec().setApproved(false); + + comments.add(commentA); + comments.add(commentB); + comments.add(commentC); + comments.add(commentD); + comments.add(commentE); + comments.add(commentF); + return comments; + } + + private Comment getComment(String name) { + final Ref ref = getRef(); + Comment comment = new Comment(); + comment.setMetadata(new Metadata()); + comment.getMetadata().setName(name); + comment.setSpec(new Comment.CommentSpec()); + comment.getSpec().setSubjectRef(ref); + comment.getSpec().setApproved(true); + comment.getSpec().setLastReadTime(now.plusSeconds(5)); + comment.setStatus(new Comment.CommentStatus()); + return comment; + } + + private static Ref getRef() { + Ref ref = new Ref(); + ref.setGroup("content.halo.run"); + ref.setVersion("v1alpha1"); + ref.setKind("Post"); + ref.setName("fake-post"); + return ref; + } + List replyList() { Reply replyA = new Reply(); replyA.setMetadata(new Metadata()); diff --git a/src/test/java/run/halo/app/metrics/CounterMeterHandlerTest.java b/src/test/java/run/halo/app/metrics/CounterMeterHandlerTest.java new file mode 100644 index 000000000..b798b6891 --- /dev/null +++ b/src/test/java/run/halo/app/metrics/CounterMeterHandlerTest.java @@ -0,0 +1,108 @@ +package run.halo.app.metrics; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; + +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; + +/** + * Tests for {@link CounterMeterHandler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class CounterMeterHandlerTest { + + @Mock + private ReactiveExtensionClient client; + + private final String counterName = MeterUtils.nameOf(Post.class, "fake-post"); + + @InjectMocks + private CounterMeterHandler counterMeterHandler; + + private MeterRegistry meterRegistry; + + @BeforeEach + void setUp() { + meterRegistry = new SimpleMeterRegistry(); + counterMeterHandler = new CounterMeterHandler(client, meterRegistry); + } + + @Test + void onApplicationReady() { + Counter counter = new Counter(); + counter.setMetadata(new Metadata()); + counter.getMetadata().setName(counterName); + counter.setVisit(1); + counter.setUpvote(3); + counter.setTotalComment(5); + counter.setApprovedComment(2); + + Mockito.when(client.list(eq(Counter.class), any(), any())) + .thenReturn(Flux.just(counter)); + + counterMeterHandler.onApplicationReady(Mockito.mock(ApplicationReadyEvent.class)) + .as(StepVerifier::create) + .verifyComplete(); + + assertThat((int) meterRegistry.find(counterName) + .tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.VISIT_SCENE)) + .counter().count()).isEqualTo(counter.getVisit()); + + assertThat((int) meterRegistry.find(counterName) + .tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.UPVOTE_SCENE)) + .counter().count()).isEqualTo(counter.getUpvote()); + assertThat((int) meterRegistry.find(counterName) + .tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.TOTAL_COMMENT_SCENE)) + .counter().count()).isEqualTo(counter.getTotalComment()); + assertThat((int) meterRegistry.find(counterName) + .tag(MeterUtils.SCENE, value -> value.equals(MeterUtils.APPROVED_COMMENT_SCENE)) + .counter().count()).isEqualTo(counter.getApprovedComment()); + } + + @Test + void save() { + MeterUtils.visitCounter(meterRegistry, counterName).increment(2); + MeterUtils.upvoteCounter(meterRegistry, counterName).increment(3); + + Mockito.when(client.create(any())) + .thenReturn(Mono.just(CounterMeterHandler.emptyCounter(counterName))); + Mockito.when(client.fetch(eq(Counter.class), eq(counterName))) + .thenReturn(Mono.empty()); + Mockito.when(client.update(any(Counter.class))) + .thenAnswer(a -> { + ArgumentCaptor captor = + ArgumentCaptor.forClass(Counter.class); + Mockito.verify(client, Mockito.times(1)).update(captor.capture()); + Counter value = captor.getValue(); + assertThat(value.getVisit()).isEqualTo(2); + assertThat(value.getUpvote()).isEqualTo(3); + assertThat(value.getTotalComment()).isEqualTo(0); + assertThat(value.getApprovedComment()).isEqualTo(0); + return Mono.just(value); + }); + counterMeterHandler.save() + .as(StepVerifier::create) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/metrics/CounterServiceImplTest.java b/src/test/java/run/halo/app/metrics/CounterServiceImplTest.java new file mode 100644 index 000000000..e5b12343f --- /dev/null +++ b/src/test/java/run/halo/app/metrics/CounterServiceImplTest.java @@ -0,0 +1,41 @@ +package run.halo.app.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import run.halo.app.core.extension.Post; + +/** + * Tests for {@link CounterServiceImpl}. + * + * @author guqing + * @since 2.0.0 + */ +class CounterServiceImplTest { + + private CounterServiceImpl counterService; + + @BeforeEach + void setUp() { + SimpleMeterRegistry simpleMeterRegistry = new SimpleMeterRegistry(); + counterService = new CounterServiceImpl(simpleMeterRegistry); + String counterName = MeterUtils.nameOf(Post.class, "fake-post"); + MeterUtils.visitCounter(simpleMeterRegistry, + counterName).increment(); + + MeterUtils.approvedCommentCounter(simpleMeterRegistry, counterName) + .increment(2); + } + + @Test + void getByName() { + run.halo.app.core.extension.Counter counter = + counterService.getByName(MeterUtils.nameOf(Post.class, "fake-post")); + assertThat(counter.getVisit()).isEqualTo(1); + assertThat(counter.getUpvote()).isEqualTo(0); + assertThat(counter.getTotalComment()).isEqualTo(0); + assertThat(counter.getApprovedComment()).isEqualTo(2); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/metrics/MeterUtilsTest.java b/src/test/java/run/halo/app/metrics/MeterUtilsTest.java new file mode 100644 index 000000000..6cee04d06 --- /dev/null +++ b/src/test/java/run/halo/app/metrics/MeterUtilsTest.java @@ -0,0 +1,119 @@ +package run.halo.app.metrics; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.Meter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.search.RequiredSearch; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import run.halo.app.core.extension.Post; + +/** + * Tests for {@link MeterUtils}. + * + * @author guqing + * @since 2.0.0 + */ +class MeterUtilsTest { + + @Test + void nameOf() { + String s = MeterUtils.nameOf(Post.class, "fake-post"); + assertThat(s).isEqualTo("posts.content.halo.run/fake-post"); + } + + @Test + void testNameOf() { + String s = MeterUtils.nameOf("content.halo.run", "posts", "fake-post"); + assertThat(s).isEqualTo("posts.content.halo.run/fake-post"); + } + + @Test + void visitCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + MeterUtils.visitCounter(meterRegistry, "posts.content.halo.run/fake-post") + .increment(); + RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); + assertThat(requiredSearch.counter().count()).isEqualTo(1); + Meter.Id id = requiredSearch.counter().getId(); + assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.VISIT_SCENE); + assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) + .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); + } + + @Test + void upvoteCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + MeterUtils.upvoteCounter(meterRegistry, "posts.content.halo.run/fake-post") + .increment(2); + RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); + assertThat(requiredSearch.counter().count()).isEqualTo(2); + Meter.Id id = requiredSearch.counter().getId(); + assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.UPVOTE_SCENE); + assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) + .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); + } + + @Test + void totalCommentCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + MeterUtils.totalCommentCounter(meterRegistry, "content.halo.run.posts.fake-post") + .increment(3); + RequiredSearch requiredSearch = meterRegistry.get("content.halo.run.posts.fake-post"); + assertThat(requiredSearch.counter().count()).isEqualTo(3); + Meter.Id id = requiredSearch.counter().getId(); + assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.TOTAL_COMMENT_SCENE); + assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) + .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); + } + + @Test + void approvedCommentCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + MeterUtils.approvedCommentCounter(meterRegistry, "posts.content.halo.run/fake-post") + .increment(2); + RequiredSearch requiredSearch = meterRegistry.get("posts.content.halo.run/fake-post"); + assertThat(requiredSearch.counter().count()).isEqualTo(2); + Meter.Id id = requiredSearch.counter().getId(); + assertThat(id.getTag(MeterUtils.SCENE)).isEqualTo(MeterUtils.APPROVED_COMMENT_SCENE); + assertThat(id.getTag(MeterUtils.METRICS_COMMON_TAG.getKey())) + .isEqualTo(MeterUtils.METRICS_COMMON_TAG.getValue()); + } + + @Test + void isVisitCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + Counter visitCounter = + MeterUtils.visitCounter(meterRegistry, "posts.content.halo.run/fake-post"); + assertThat(MeterUtils.isVisitCounter(visitCounter)).isTrue(); + } + + @Test + void isUpvoteCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + Counter upvoteCounter = + MeterUtils.upvoteCounter(meterRegistry, "posts.content.halo.run/fake-post"); + assertThat(MeterUtils.isUpvoteCounter(upvoteCounter)).isTrue(); + assertThat(MeterUtils.isVisitCounter(upvoteCounter)).isFalse(); + } + + @Test + void isTotalCommentCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + Counter totalCommentCounter = + MeterUtils.totalCommentCounter(meterRegistry, "posts.content.halo.run/fake-post"); + assertThat(MeterUtils.isTotalCommentCounter(totalCommentCounter)).isTrue(); + assertThat(MeterUtils.isVisitCounter(totalCommentCounter)).isFalse(); + } + + @Test + void isApprovedCommentCounter() { + MeterRegistry meterRegistry = new SimpleMeterRegistry(); + Counter approvedCommentCounter = + MeterUtils.approvedCommentCounter(meterRegistry, "posts.content.halo.run/fake-post"); + assertThat(MeterUtils.isApprovedCommentCounter(approvedCommentCounter)).isTrue(); + assertThat(MeterUtils.isVisitCounter(approvedCommentCounter)).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java b/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java index 3ea473f15..85158ca15 100644 --- a/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java +++ b/src/test/java/run/halo/app/theme/finders/impl/PostFinderImplTest.java @@ -7,9 +7,9 @@ import static org.mockito.Mockito.when; import java.time.Instant; import java.util.List; import java.util.Map; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Mono; @@ -47,14 +47,9 @@ class PostFinderImplTest { @Mock private ContributorFinder contributorFinder; + @InjectMocks private PostFinderImpl postFinder; - @BeforeEach - void setUp() { - postFinder = new PostFinderImpl(client, contentService, tagFinder, categoryFinder, - contributorFinder); - } - @Test void content() { Post post = post(1);