feat: add upvote and downvote tracker (#2566)

#### What type of PR is this?
/kind feature
/area core
/milestone 2.0
/kind api-change

#### What this PR does / why we need it:
新增文章和评论等资源的点赞和踩 API

#### Which issue(s) this PR fixes:

Fixes #2565

#### Special notes for your reviewer:
how to test it?
1.  创建并发布一篇文章 替换下面的 `your-post-name` 然后执行它
```curl
curl --location --request POST 'http://localhost:8090/apis/api.halo.run/v1alpha1/trackers/upvote' \
--header 'Content-Type: application/json' \
--data-raw '{
    "group": "content.halo.run",
    "plural": "posts",
    "name": your-post-name
}
```
2. 请求成功并得到 response 为点赞数
3. 踩的 API 为 `http://localhost:8090/apis/api.halo.run/v1alpha1/trackers/downvote` 请求体与步骤1相同的测试方法
4. 重启 Halo 后 Counter 数据应该依然存在并且正确

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
新增文章和评论等资源的点赞和踩 API
```
pull/2582/head
guqing 2022-10-15 12:53:34 +08:00 committed by GitHub
parent 83b40b6dad
commit 08fe1858cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 100 additions and 5 deletions

View File

@ -24,6 +24,8 @@ public class Counter extends AbstractExtension {
private Integer upvote;
private Integer downvote;
private Integer totalComment;
private Integer approvedComment;
@ -41,6 +43,8 @@ public class Counter extends AbstractExtension {
this.visit = (int) meterCounter.count();
} else if (MeterUtils.isUpvoteCounter(meterCounter)) {
this.upvote = (int) meterCounter.count();
} else if (MeterUtils.isDownvoteCounter(meterCounter)) {
this.downvote = (int) meterCounter.count();
} else if (MeterUtils.isTotalCommentCounter(meterCounter)) {
this.totalComment = (int) meterCounter.count();
} else if (MeterUtils.isApprovedCommentCounter(meterCounter)) {
@ -53,6 +57,7 @@ public class Counter extends AbstractExtension {
private void populateDefaultValue() {
this.visit = 0;
this.upvote = 0;
this.downvote = 0;
this.totalComment = 0;
this.approvedComment = 0;
}

View File

@ -43,7 +43,7 @@ public class TrackerEndpoint implements CustomEndpoint {
public RouterFunction<ServerResponse> endpoint() {
final var tag = "api.halo.run/v1alpha1/Tracker";
return SpringdocRouteBuilder.route()
.POST("trackers/counter", this::increase,
.POST("trackers/counter", this::increaseVisit,
builder -> builder.operationId("count")
.description("Count an extension resource visits.")
.tag(tag)
@ -55,12 +55,40 @@ public class TrackerEndpoint implements CustomEndpoint {
.implementation(CounterRequest.class))
))
.response(responseBuilder()
.implementation(Double.class))
.implementation(Integer.class))
)
.POST("trackers/upvote", this::upvote,
builder -> builder.operationId("upvote")
.description("Upvote an extension resource.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(VoteRequest.class))
))
.response(responseBuilder()
.implementation(Integer.class))
)
.POST("trackers/downvote", this::downvote,
builder -> builder.operationId("downvote")
.description("Downvote an extension resource.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.APPLICATION_JSON_VALUE)
.schema(Builder.schemaBuilder()
.implementation(VoteRequest.class))
))
.response(responseBuilder()
.implementation(Integer.class))
)
.build();
}
private Mono<ServerResponse> increase(ServerRequest request) {
private Mono<ServerResponse> increaseVisit(ServerRequest request) {
return request.bodyToMono(CounterRequest.class)
.switchIfEmpty(
Mono.error(new IllegalArgumentException("Counter request body must not be empty")))
@ -78,6 +106,41 @@ public class TrackerEndpoint implements CustomEndpoint {
.flatMap(count -> ServerResponse.ok().bodyValue(count));
}
private Mono<ServerResponse> upvote(ServerRequest request) {
return request.bodyToMono(VoteRequest.class)
.switchIfEmpty(
Mono.error(new IllegalArgumentException("Upvote request body must not be empty")))
.map(voteRequest -> {
String counterName =
MeterUtils.nameOf(voteRequest.group(), voteRequest.plural(),
voteRequest.name());
Counter counter = MeterUtils.upvoteCounter(meterRegistry, counterName);
counter.increment();
return (int) counter.count();
})
.flatMap(count -> ServerResponse.ok().bodyValue(count));
}
private Mono<ServerResponse> downvote(ServerRequest request) {
return request.bodyToMono(VoteRequest.class)
.switchIfEmpty(
Mono.error(new IllegalArgumentException("Downvote request body must not be empty")))
.map(voteRequest -> {
String counterName =
MeterUtils.nameOf(voteRequest.group(), voteRequest.plural(),
voteRequest.name());
Counter counter = MeterUtils.downvoteCounter(meterRegistry, counterName);
counter.increment();
return (int) counter.count();
})
.flatMap(count -> ServerResponse.ok().bodyValue(count));
}
public record VoteRequest(String group, String plural, String name) {
}
public record CounterRequest(String group, String plural, String name, String hostname,
String screen, String language, String referrer) {
/**

View File

@ -59,6 +59,11 @@ public class CounterMeterHandler implements DisposableBean {
MeterUtils.upvoteCounter(meterRegistry, name);
upvoteCounter.increment(nullSafe(counter.getUpvote()));
// downvote counter
io.micrometer.core.instrument.Counter downvoteCounter =
MeterUtils.downvoteCounter(meterRegistry, name);
downvoteCounter.increment(nullSafe(counter.getDownvote()));
// total comment counter
io.micrometer.core.instrument.Counter totalCommentCounter =
MeterUtils.totalCommentCounter(meterRegistry, name);

View File

@ -20,6 +20,7 @@ public class MeterUtils {
public static final String SCENE = "scene";
public static final String VISIT_SCENE = "visit";
public static final String UPVOTE_SCENE = "upvote";
public static final String DOWNVOTE_SCENE = "downvote";
public static final String TOTAL_COMMENT_SCENE = "total_comment";
public static final String APPROVED_COMMENT_SCENE = "approved_comment";
@ -51,6 +52,10 @@ public class MeterUtils {
return counter(registry, name, Tag.of(SCENE, UPVOTE_SCENE));
}
public static Counter downvoteCounter(MeterRegistry registry, String name) {
return counter(registry, name, Tag.of(SCENE, DOWNVOTE_SCENE));
}
public static Counter totalCommentCounter(MeterRegistry registry, String name) {
return counter(registry, name, Tag.of(SCENE, TOTAL_COMMENT_SCENE));
}
@ -75,6 +80,14 @@ public class MeterUtils {
return UPVOTE_SCENE.equals(sceneValue);
}
public static boolean isDownvoteCounter(Counter counter) {
String sceneValue = counter.getId().getTag(SCENE);
if (StringUtils.isBlank(sceneValue)) {
return false;
}
return DOWNVOTE_SCENE.equals(sceneValue);
}
public static boolean isTotalCommentCounter(Counter counter) {
String sceneValue = counter.getId().getTag(SCENE);
if (StringUtils.isBlank(sceneValue)) {

View File

@ -125,7 +125,7 @@ public class RbacRequestEvaluation {
if (Objects.equals(ruleURL, requestedURL)) {
return true;
}
if (StringUtils.startsWith(ruleURL, WildCard.NonResourceAll)
if (StringUtils.endsWith(ruleURL, WildCard.NonResourceAll)
&& StringUtils.startsWith(requestedURL,
StringUtils.stripEnd(ruleURL, WildCard.NonResourceAll))) {
return true;

View File

@ -12,5 +12,5 @@ rules:
- apiGroups: [ "api.halo.run" ]
resources: [ "*" ]
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/counter" ]
- nonResourceURLs: [ "/apis/api.halo.run/v1alpha1/trackers/*" ]
verbs: [ "create" ]

View File

@ -99,6 +99,15 @@ class MeterUtilsTest {
assertThat(MeterUtils.isVisitCounter(upvoteCounter)).isFalse();
}
@Test
void isDownvoteCounter() {
MeterRegistry meterRegistry = new SimpleMeterRegistry();
Counter downvoteCounter =
MeterUtils.downvoteCounter(meterRegistry, "posts.content.halo.run/fake-post");
assertThat(MeterUtils.isDownvoteCounter(downvoteCounter)).isTrue();
assertThat(MeterUtils.isVisitCounter(downvoteCounter)).isFalse();
}
@Test
void isTotalCommentCounter() {
MeterRegistry meterRegistry = new SimpleMeterRegistry();