feat: add post and single page statistics (#2476)

#### 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
```
pull/2488/head
guqing 2022-09-30 15:58:19 +08:00 committed by GitHub
parent a3aba94102
commit eaa18573f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1516 additions and 50 deletions

View File

@ -4,6 +4,7 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration; import org.springframework.boot.autoconfigure.integration.IntegrationAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; 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.HaloProperties;
import run.halo.app.infra.properties.JwtProperties; import run.halo.app.infra.properties.JwtProperties;
@ -15,6 +16,7 @@ import run.halo.app.infra.properties.JwtProperties;
* @author guqing * @author guqing
* @date 2017-11-14 * @date 2017-11-14
*/ */
@EnableScheduling
@SpringBootApplication(scanBasePackages = "run.halo.app", exclude = @SpringBootApplication(scanBasePackages = "run.halo.app", exclude =
IntegrationAutoConfiguration.class) IntegrationAutoConfiguration.class)
@EnableConfigurationProperties({HaloProperties.class, JwtProperties.class}) @EnableConfigurationProperties({HaloProperties.class, JwtProperties.class})

View File

@ -1,5 +1,6 @@
package run.halo.app.config; package run.halo.app.config;
import io.micrometer.core.instrument.MeterRegistry;
import org.pf4j.PluginManager; import org.pf4j.PluginManager;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
@ -202,9 +203,10 @@ public class ExtensionConfiguration {
} }
@Bean @Bean
Controller commentController(ExtensionClient client) { Controller commentController(ExtensionClient client, MeterRegistry meterRegistry,
SchemeManager schemeManager) {
return new ControllerBuilder("comment-controller", client) return new ControllerBuilder("comment-controller", client)
.reconciler(new CommentReconciler(client)) .reconciler(new CommentReconciler(client, meterRegistry, schemeManager))
.extension(new Comment()) .extension(new Comment())
.build(); .build();
} }

View File

@ -28,4 +28,7 @@ public class ListedPost {
@Schema(required = true) @Schema(required = true)
private List<Contributor> contributors; private List<Contributor> contributors;
@Schema(required = true)
private Stats stats;
} }

View File

@ -20,4 +20,7 @@ public class ListedSinglePage {
@Schema(required = true) @Schema(required = true)
private List<Contributor> contributors; private List<Contributor> contributors;
@Schema(required = true)
private Stats stats;
} }

View File

@ -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;
}

View File

@ -24,7 +24,9 @@ import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest; import run.halo.app.content.PostRequest;
import run.halo.app.content.PostService; import run.halo.app.content.PostService;
import run.halo.app.content.PostSorter; 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.Category;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Post;
import run.halo.app.core.extension.Snapshot; import run.halo.app.core.extension.Snapshot;
import run.halo.app.core.extension.Tag; 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.extension.ReactiveExtensionClient;
import run.halo.app.infra.Condition; import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ConditionStatus;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
/** /**
* A default implementation of {@link PostService}. * A default implementation of {@link PostService}.
@ -44,10 +48,13 @@ import run.halo.app.infra.ConditionStatus;
public class PostServiceImpl implements PostService { public class PostServiceImpl implements PostService {
private final ContentService contentService; private final ContentService contentService;
private final ReactiveExtensionClient client; 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.contentService = contentService;
this.client = client; this.client = client;
this.counterService = counterService;
} }
@Override @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<Post> postListPredicate(PostQuery query) { Predicate<Post> postListPredicate(PostQuery query) {
Predicate<Post> paramPredicate = post -> Predicate<Post> paramPredicate = post ->
contains(query.getCategories(), post.getSpec().getCategories()) contains(query.getCategories(), post.getSpec().getCategories())
@ -132,6 +152,7 @@ public class PostServiceImpl implements PostService {
.map(p -> { .map(p -> {
ListedPost listedPost = new ListedPost(); ListedPost listedPost = new ListedPost();
listedPost.setPost(p); listedPost.setPost(p);
listedPost.setStats(fetchStats(post));
return listedPost; return listedPost;
}) })
.flatMap(lp -> setTags(post.getSpec().getTags(), lp)) .flatMap(lp -> setTags(post.getSpec().getTags(), lp))

View File

@ -24,6 +24,8 @@ import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageRequest;
import run.halo.app.content.SinglePageService; import run.halo.app.content.SinglePageService;
import run.halo.app.content.SinglePageSorter; 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.Post;
import run.halo.app.core.extension.SinglePage; import run.halo.app.core.extension.SinglePage;
import run.halo.app.core.extension.Snapshot; 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.extension.ReactiveExtensionClient;
import run.halo.app.infra.Condition; import run.halo.app.infra.Condition;
import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ConditionStatus;
import run.halo.app.metrics.CounterService;
import run.halo.app.metrics.MeterUtils;
/** /**
* Single page service implementation. * Single page service implementation.
@ -45,9 +49,13 @@ public class SinglePageServiceImpl implements SinglePageService {
private final ReactiveExtensionClient client; 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.contentService = contentService;
this.client = client; this.client = client;
this.counterService = counterService;
} }
@Override @Override
@ -179,6 +187,7 @@ public class SinglePageServiceImpl implements SinglePageService {
.map(sp -> { .map(sp -> {
ListedSinglePage listedSinglePage = new ListedSinglePage(); ListedSinglePage listedSinglePage = new ListedSinglePage();
listedSinglePage.setPage(singlePage); listedSinglePage.setPage(singlePage);
listedSinglePage.setStats(fetchStats(singlePage));
return listedSinglePage; return listedSinglePage;
}) })
.flatMap(lsp -> .flatMap(lsp ->
@ -194,6 +203,19 @@ public class SinglePageServiceImpl implements SinglePageService {
.defaultIfEmpty(singlePage); .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<Contributor> listContributors(List<String> usernames) { private Flux<Contributor> listContributors(List<String> usernames) {
if (usernames == null) { if (usernames == null) {
return Flux.empty(); return Flux.empty();

View File

@ -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 <T extends Meter> void populateFrom(Collection<T> 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;
}
}

View File

@ -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 static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn; 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.core.fn.builders.schema.Builder;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction; 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.core.extension.Reply;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.router.QueryParamBuildUtil; 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.infra.utils.IpAddressUtils;
@ -106,7 +105,7 @@ public class CommentEndpoint implements CustomEndpoint {
.flatMap(commentRequest -> { .flatMap(commentRequest -> {
Comment comment = commentRequest.toComment(); Comment comment = commentRequest.toComment();
comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
comment.getSpec().setUserAgent(userAgentFrom(request)); comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
return commentService.create(comment); return commentService.create(comment);
}) })
.flatMap(comment -> ServerResponse.ok().bodyValue(comment)); .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
@ -118,26 +117,10 @@ public class CommentEndpoint implements CustomEndpoint {
.flatMap(replyRequest -> { .flatMap(replyRequest -> {
Reply reply = replyRequest.toReply(); Reply reply = replyRequest.toReply();
reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
reply.getSpec().setUserAgent(userAgentFrom(request)); reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
return replyService.create(commentName, reply); return replyService.create(commentName, reply);
}) })
.flatMap(comment -> ServerResponse.ok().bodyValue(comment)); .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");
}
} }

View File

@ -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<ServerResponse> 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<ServerResponse> 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;
}
}
}

View File

@ -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<ServerResponse> 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<ServerResponse> 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");
}
}

View File

@ -1,17 +1,27 @@
package run.halo.app.core.extension.reconciler; 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Function; 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.Comment;
import run.halo.app.core.extension.Reply; import run.halo.app.core.extension.Reply;
import run.halo.app.extension.ExtensionClient; 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.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.MeterUtils;
/** /**
* Reconciler for {@link Comment}. * Reconciler for {@link Comment}.
@ -22,9 +32,14 @@ import run.halo.app.infra.utils.JsonUtils;
public class CommentReconciler implements Reconciler<Reconciler.Request> { public class CommentReconciler implements Reconciler<Reconciler.Request> {
public static final String FINALIZER_NAME = "comment-protection"; public static final String FINALIZER_NAME = "comment-protection";
private final ExtensionClient client; 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.client = client;
this.meterRegistry = meterRegistry;
this.schemeManager = schemeManager;
} }
@Override @Override
@ -37,6 +52,7 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
} }
addFinalizerIfNecessary(comment); addFinalizerIfNecessary(comment);
reconcileStatus(request.name()); reconcileStatus(request.name());
reconcileCommentCount();
return new Result(true, Duration.ofMinutes(1)); return new Result(true, Duration.ofMinutes(1));
}) })
.orElseGet(() -> new Result(false, null)); .orElseGet(() -> new Result(false, null));
@ -95,6 +111,67 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
}); });
} }
private void reconcileCommentCount() {
Map<Ref, List<RefCommentTuple>> 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) { private void cleanUpResourcesAndRemoveFinalizer(String commentName) {
client.fetch(Comment.class, commentName).ifPresent(comment -> { client.fetch(Comment.class, commentName).ifPresent(comment -> {
cleanUpResources(comment); cleanUpResources(comment);
@ -111,6 +188,27 @@ public class CommentReconciler implements Reconciler<Reconciler.Request> {
.equals(reply.getSpec().getCommentName()), .equals(reply.getSpec().getCommentName()),
null) null)
.forEach(client::delete); .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<Reply> defaultReplyComparator() { Comparator<Reply> defaultReplyComparator() {

View File

@ -6,6 +6,7 @@ import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import run.halo.app.core.extension.Category; import run.halo.app.core.extension.Category;
import run.halo.app.core.extension.Comment; 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.Menu;
import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.MenuItem;
import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Plugin;
@ -62,5 +63,7 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
schemeManager.register(Policy.class); schemeManager.register(Policy.class);
schemeManager.register(Attachment.class); schemeManager.register(Attachment.class);
schemeManager.register(PolicyTemplate.class); schemeManager.register(PolicyTemplate.class);
// metrics.halo.run
schemeManager.register(Counter.class);
} }
} }

View File

@ -4,8 +4,11 @@ import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpHeaders;
import org.springframework.util.StreamUtils; import org.springframework.util.StreamUtils;
import org.springframework.web.reactive.function.server.ServerRequest;
/** /**
* @author guqing * @author guqing
@ -31,4 +34,20 @@ public class HaloUtils {
} }
} }
/**
* 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");
}
} }

View File

@ -0,0 +1,137 @@
package run.halo.app.metrics;
import io.micrometer.core.instrument.Meter;
import io.micrometer.core.instrument.MeterRegistry;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.DisposableBean;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Counter;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.utils.JsonUtils;
/**
* Counter meter handler for {@link Counter}.
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Component
public class CounterMeterHandler implements DisposableBean {
private final ReactiveExtensionClient client;
private final MeterRegistry meterRegistry;
public CounterMeterHandler(ReactiveExtensionClient client, MeterRegistry meterRegistry) {
this.client = client;
this.meterRegistry = meterRegistry;
}
/**
* Synchronize counter meters from {@link Counter}.
*
* @param event application ready event
*/
@EventListener(ApplicationReadyEvent.class)
public Mono<Void> 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<Void> save() {
Map<String, List<Meter>> 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<Mono<Counter>> monoStream = nameMeters.entrySet().stream()
.map(entry -> {
String name = entry.getKey();
List<Meter> 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();
}
}

View File

@ -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);
}

View File

@ -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<io.micrometer.core.instrument.Counter> 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;
}
}

View File

@ -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 <T extends AbstractExtension> String nameOf(Class<T> 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;
}
}

View File

@ -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<String> 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);
}
}
}
}

View File

@ -68,7 +68,7 @@ public class GlobalHeadInjectionProcessor extends AbstractElementModelProcessor
getTemplateHeadProcessors(appCtx); getTemplateHeadProcessors(appCtx);
for (TemplateHeadProcessor processor : templateHeadProcessors) { for (TemplateHeadProcessor processor : templateHeadProcessors) {
processor.process(context, modelToInsert, structureHandler) processor.process(context, modelToInsert, structureHandler)
.subscribe(); .block();
} }
// add to target model // add to target model

View File

@ -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<Void> 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 """
<script async defer src="%s" data-group="%s" data-plural="%s" data-name="%s"></script>
""".formatted(jsSrc, group, plural, name);
}
}

View File

@ -26,13 +26,13 @@ import run.halo.app.content.comment.ReplyRequest;
import run.halo.app.content.comment.ReplyService; import run.halo.app.content.comment.ReplyService;
import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Comment;
import run.halo.app.core.extension.Reply; 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.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion; import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.Ref; import run.halo.app.extension.Ref;
import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.IListRequest;
import run.halo.app.extension.router.QueryParamBuildUtil; 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.infra.utils.IpAddressUtils;
import run.halo.app.theme.finders.CommentFinder; import run.halo.app.theme.finders.CommentFinder;
import run.halo.app.theme.finders.vo.CommentVo; import run.halo.app.theme.finders.vo.CommentVo;
@ -145,7 +145,7 @@ public class CommentFinderEndpoint implements CustomEndpoint {
.flatMap(commentRequest -> { .flatMap(commentRequest -> {
Comment comment = commentRequest.toComment(); Comment comment = commentRequest.toComment();
comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); comment.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
comment.getSpec().setUserAgent(CommentEndpoint.userAgentFrom(request)); comment.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
return commentService.create(comment); return commentService.create(comment);
}) })
.flatMap(comment -> ServerResponse.ok().bodyValue(comment)); .flatMap(comment -> ServerResponse.ok().bodyValue(comment));
@ -157,7 +157,7 @@ public class CommentFinderEndpoint implements CustomEndpoint {
.flatMap(replyRequest -> { .flatMap(replyRequest -> {
Reply reply = replyRequest.toReply(); Reply reply = replyRequest.toReply();
reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request)); reply.getSpec().setIpAddress(IpAddressUtils.getIpAddress(request));
reply.getSpec().setUserAgent(CommentEndpoint.userAgentFrom(request)); reply.getSpec().setUserAgent(HaloUtils.userAgentFrom(request));
return replyService.create(commentName, reply); return replyService.create(commentName, reply);
}) })
.flatMap(comment -> ServerResponse.ok().bodyValue(comment)); .flatMap(comment -> ServerResponse.ok().bodyValue(comment));

View File

@ -10,9 +10,12 @@ import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import run.halo.app.content.ContentService; 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.Post;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient; 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.CategoryFinder;
import run.halo.app.theme.finders.ContributorFinder; import run.halo.app.theme.finders.ContributorFinder;
import run.halo.app.theme.finders.Finder; 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.ContentVo;
import run.halo.app.theme.finders.vo.Contributor; import run.halo.app.theme.finders.vo.Contributor;
import run.halo.app.theme.finders.vo.PostVo; import run.halo.app.theme.finders.vo.PostVo;
import run.halo.app.theme.finders.vo.StatsVo;
import run.halo.app.theme.finders.vo.TagVo; import run.halo.app.theme.finders.vo.TagVo;
/** /**
@ -47,16 +51,19 @@ public class PostFinderImpl implements PostFinder {
private final ContributorFinder contributorFinder; private final ContributorFinder contributorFinder;
private final CounterService counterService;
public PostFinderImpl(ReactiveExtensionClient client, public PostFinderImpl(ReactiveExtensionClient client,
ContentService contentService, ContentService contentService,
TagFinder tagFinder, TagFinder tagFinder,
CategoryFinder categoryFinder, CategoryFinder categoryFinder,
ContributorFinder contributorFinder) { ContributorFinder contributorFinder, CounterService counterService) {
this.client = client; this.client = client;
this.contentService = contentService; this.contentService = contentService;
this.tagFinder = tagFinder; this.tagFinder = tagFinder;
this.categoryFinder = categoryFinder; this.categoryFinder = categoryFinder;
this.contributorFinder = contributorFinder; this.contributorFinder = contributorFinder;
this.counterService = counterService;
} }
@Override @Override
@ -116,10 +123,23 @@ public class PostFinderImpl implements PostFinder {
} }
List<PostVo> postVos = list.get() List<PostVo> postVos = list.get()
.map(this::getPostVo) .map(this::getPostVo)
.peek(this::populateStats)
.toList(); .toList();
return new ListResult<>(list.getPage(), list.getSize(), list.getTotal(), postVos); 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) { private PostVo getPostVo(@NonNull Post post) {
List<TagVo> tags = tagFinder.getByNames(post.getSpec().getTags()); List<TagVo> tags = tagFinder.getByNames(post.getSpec().getTags());
List<CategoryVo> categoryVos = categoryFinder.getByNames(post.getSpec().getCategories()); List<CategoryVo> categoryVos = categoryFinder.getByNames(post.getSpec().getCategories());
@ -129,6 +149,7 @@ public class PostFinderImpl implements PostFinder {
postVo.setCategories(categoryVos); postVo.setCategories(categoryVos);
postVo.setTags(tags); postVo.setTags(tags);
postVo.setContributors(contributors); postVo.setContributors(contributors);
populateStats(postVo);
return postVo; return postVo;
} }

View File

@ -8,16 +8,20 @@ import java.util.function.Function;
import java.util.function.Predicate; import java.util.function.Predicate;
import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.ObjectUtils;
import run.halo.app.content.ContentService; 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.Post;
import run.halo.app.core.extension.SinglePage; import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient; 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.ContributorFinder;
import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.SinglePageFinder;
import run.halo.app.theme.finders.vo.ContentVo; import run.halo.app.theme.finders.vo.ContentVo;
import run.halo.app.theme.finders.vo.Contributor; import run.halo.app.theme.finders.vo.Contributor;
import run.halo.app.theme.finders.vo.SinglePageVo; import run.halo.app.theme.finders.vo.SinglePageVo;
import run.halo.app.theme.finders.vo.StatsVo;
/** /**
* A default implementation of {@link SinglePage}. * A default implementation of {@link SinglePage}.
@ -39,11 +43,14 @@ public class SinglePageFinderImpl implements SinglePageFinder {
private final ContributorFinder contributorFinder; private final ContributorFinder contributorFinder;
private final CounterService counterService;
public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService, public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService,
ContributorFinder contributorFinder) { ContributorFinder contributorFinder, CounterService counterService) {
this.client = client; this.client = client;
this.contentService = contentService; this.contentService = contentService;
this.contributorFinder = contributorFinder; this.contributorFinder = contributorFinder;
this.counterService = counterService;
} }
@Override @Override
@ -58,6 +65,7 @@ public class SinglePageFinderImpl implements SinglePageFinder {
SinglePageVo pageVo = SinglePageVo.from(page); SinglePageVo pageVo = SinglePageVo.from(page);
pageVo.setContributors(contributors); pageVo.setContributors(contributors);
pageVo.setContent(content(pageName)); pageVo.setContent(content(pageName));
populateStats(pageVo);
return pageVo; return pageVo;
} }
@ -79,16 +87,29 @@ public class SinglePageFinderImpl implements SinglePageFinder {
if (list == null) { if (list == null) {
return new ListResult<>(0, 0, 0, List.of()); return new ListResult<>(0, 0, 0, List.of());
} }
List<SinglePageVo> postVos = list.get() List<SinglePageVo> pageVos = list.get()
.map(sp -> { .map(sp -> {
List<Contributor> contributors = List<Contributor> contributors =
contributorFinder.getContributors(sp.getStatus().getContributors()); contributorFinder.getContributors(sp.getStatus().getContributors());
SinglePageVo pageVo = SinglePageVo.from(sp); SinglePageVo pageVo = SinglePageVo.from(sp);
pageVo.setContributors(contributors); pageVo.setContributors(contributors);
populateStats(pageVo);
return pageVo; return pageVo;
}) })
.toList(); .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<SinglePage> defaultComparator() { static Comparator<SinglePage> defaultComparator() {

View File

@ -35,6 +35,8 @@ public class PostVo {
private List<Contributor> contributors; private List<Contributor> contributors;
private StatsVo stats;
/** /**
* Convert {@link Post} to {@link PostVo}. * Convert {@link Post} to {@link PostVo}.
* *

View File

@ -29,6 +29,8 @@ public class SinglePageVo {
private ContentVo content; private ContentVo content;
private StatsVo stats;
private List<Contributor> contributors; private List<Contributor> contributors;
/** /**

View File

@ -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;
}

View File

@ -21,6 +21,7 @@ import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import run.halo.app.content.permalinks.ExtensionLocator; import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Post;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.GroupVersionKind;
import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.PostFinder; import run.halo.app.theme.finders.PostFinder;
@ -49,7 +50,7 @@ public class PostRouteStrategy implements TemplateRouterStrategy {
public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) { public RouterFunction<ServerResponse> getRouteFunction(String template, String pattern) {
PostRequestParamPredicate postParamPredicate = PostRequestParamPredicate postParamPredicate =
new PostRequestParamPredicate(pattern); new PostRequestParamPredicate(pattern);
GVK gvk = Post.class.getAnnotation(GVK.class);
if (postParamPredicate.isQueryParamPattern()) { if (postParamPredicate.isQueryParamPattern()) {
String paramName = postParamPredicate.getParamName(); String paramName = postParamPredicate.getParamName();
String placeholderName = postParamPredicate.getPlaceholderName(); String placeholderName = postParamPredicate.getPlaceholderName();
@ -72,10 +73,14 @@ public class PostRouteStrategy implements TemplateRouterStrategy {
if (name == null) { if (name == null) {
return ServerResponse.notFound().build(); return ServerResponse.notFound().build();
} }
GroupVersionKind groupVersionKind =
new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind());
return ServerResponse.ok() return ServerResponse.ok()
.render(DefaultTemplateEnum.POST.getValue(), .render(DefaultTemplateEnum.POST.getValue(),
Map.of(PostRequestParamPredicate.NAME_PARAM, name, 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.putAll(pathMatchInfo.getUriVariables());
} }
model.put("post", postByName(locator.name())); model.put("post", postByName(locator.name()));
model.put("groupVersionKind", locator.gvk());
model.put("plural", gvk.plural());
return ServerResponse.ok() return ServerResponse.ok()
.render(DefaultTemplateEnum.POST.getValue(), model); .render(DefaultTemplateEnum.POST.getValue(), model);
}); });

View File

@ -14,6 +14,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers; import reactor.core.scheduler.Schedulers;
import run.halo.app.core.extension.SinglePage; import run.halo.app.core.extension.SinglePage;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupVersionKind; import run.halo.app.extension.GroupVersionKind;
import run.halo.app.theme.DefaultTemplateEnum; import run.halo.app.theme.DefaultTemplateEnum;
import run.halo.app.theme.finders.SinglePageFinder; import run.halo.app.theme.finders.SinglePageFinder;
@ -60,10 +61,17 @@ public class SinglePageRouteStrategy implements TemplateRouterStrategy {
return ServerResponse.ok() return ServerResponse.ok()
.render(DefaultTemplateEnum.SINGLE_PAGE.getValue(), .render(DefaultTemplateEnum.SINGLE_PAGE.getValue(),
Map.of("name", name, Map.of("name", name,
"groupVersionKind", gvk,
"plural", getPlural(),
"singlePage", singlePageByName(name))); "singlePage", singlePageByName(name)));
}); });
} }
private String getPlural() {
GVK annotation = SinglePage.class.getAnnotation(GVK.class);
return annotation.plural();
}
private Mono<SinglePageVo> singlePageByName(String name) { private Mono<SinglePageVo> singlePageByName(String name) {
return Mono.defer(() -> Mono.just(singlePageFinder.getByName(name))) return Mono.defer(() -> Mono.just(singlePageFinder.getByName(name)))
.publishOn(Schedulers.boundedElastic()); .publishOn(Schedulers.boundedElastic());

View File

@ -9,3 +9,8 @@ rules:
- apiGroups: [ "api.halo.run" ] - apiGroups: [ "api.halo.run" ]
resources: [ "comments", "comments/reply" ] resources: [ "comments", "comments/reply" ]
verbs: [ "create", "get", "list" ] verbs: [ "create", "get", "list" ]
- apiGroups: [ "api.halo.run" ]
resources: [ "*" ]
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/counter" ]
verbs: [ "post" ]

View File

@ -49,3 +49,15 @@ rules:
resources: [ "users/password" ] resources: [ "users/password" ]
resourceNames: [ "-" ] resourceNames: [ "-" ]
verbs: [ "update" ] 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" ]

View File

@ -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)}();

View File

@ -5,9 +5,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
@ -32,13 +32,9 @@ class PostServiceImplTest {
@Mock @Mock
private ContentService contentService; private ContentService contentService;
@InjectMocks
private PostServiceImpl postService; private PostServiceImpl postService;
@BeforeEach
void setUp() {
postService = new PostServiceImpl(contentService, client);
}
@Test @Test
void listPredicate() { void listPredicate() {
MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>(); MultiValueMap<String, String> multiValueMap = new LinkedMultiValueMap<>();

View File

@ -38,6 +38,7 @@ import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.exception.ExtensionNotFoundException; import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher; import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.utils.JsonUtils; import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.metrics.CounterMeterHandler;
@SpringBootTest @SpringBootTest
@AutoConfigureWebTestClient @AutoConfigureWebTestClient
@ -56,6 +57,9 @@ class UserEndpointTest {
@MockBean @MockBean
UserService userService; UserService userService;
@MockBean
CounterMeterHandler counterMeterHandler;
@MockBean @MockBean
SystemConfigurableEnvironmentFetcher environmentFetcher; SystemConfigurableEnvironmentFetcher environmentFetcher;

View File

@ -6,24 +6,37 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set; import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import run.halo.app.core.extension.Comment; 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.core.extension.Reply;
import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Metadata; 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.extension.controller.Reconciler;
import run.halo.app.metrics.MeterUtils;
/** /**
* Tests for {@link CommentReconciler}. * Tests for {@link CommentReconciler}.
@ -37,11 +50,19 @@ class CommentReconcilerTest {
@Mock @Mock
private ExtensionClient client; private ExtensionClient client;
@InjectMocks private final MeterRegistry meterRegistry = new SimpleMeterRegistry();
@Mock
SchemeManager schemeManager;
private CommentReconciler commentReconciler; private CommentReconciler commentReconciler;
private final Instant now = Instant.now(); private final Instant now = Instant.now();
@BeforeEach
void setUp() {
commentReconciler = new CommentReconciler(client, meterRegistry, schemeManager);
}
@Test @Test
void reconcile() { void reconcile() {
Comment comment = new Comment(); Comment comment = new Comment();
@ -112,6 +133,94 @@ class CommentReconcilerTest {
.contains(CommentReconciler.FINALIZER_NAME)).isFalse(); .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<Comment> commentList() {
final List<Comment> 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<Reply> replyList() { List<Reply> replyList() {
Reply replyA = new Reply(); Reply replyA = new Reply();
replyA.setMetadata(new Metadata()); replyA.setMetadata(new Metadata());

View File

@ -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<Counter> 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();
}
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -7,9 +7,9 @@ import static org.mockito.Mockito.when;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@ -47,14 +47,9 @@ class PostFinderImplTest {
@Mock @Mock
private ContributorFinder contributorFinder; private ContributorFinder contributorFinder;
@InjectMocks
private PostFinderImpl postFinder; private PostFinderImpl postFinder;
@BeforeEach
void setUp() {
postFinder = new PostFinderImpl(client, contentService, tagFinder, categoryFinder,
contributorFinder);
}
@Test @Test
void content() { void content() {
Post post = post(1); Post post = post(1);