diff --git a/api/src/main/java/run/halo/app/core/extension/content/Post.java b/api/src/main/java/run/halo/app/core/extension/content/Post.java index 861a99fa9..5fb3f16e0 100644 --- a/api/src/main/java/run/halo/app/core/extension/content/Post.java +++ b/api/src/main/java/run/halo/app/core/extension/content/Post.java @@ -43,6 +43,8 @@ public class Post extends AbstractExtension { "content.halo.run/last-released-snapshot"; public static final String LAST_ASSOCIATED_TAGS_ANNO = "content.halo.run/last-associated-tags"; + public static final String STATS_ANNO = "content.halo.run/stats"; + public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String PUBLISHED_LABEL = "content.halo.run/published"; public static final String OWNER_LABEL = "content.halo.run/owner"; diff --git a/application/src/main/java/run/halo/app/content/Stats.java b/application/src/main/java/run/halo/app/content/Stats.java index 3a3f4fabc..151f92689 100644 --- a/application/src/main/java/run/halo/app/content/Stats.java +++ b/application/src/main/java/run/halo/app/content/Stats.java @@ -1,7 +1,7 @@ package run.halo.app.content; import lombok.Builder; -import lombok.Value; +import lombok.Data; /** * Stats value object. @@ -9,17 +9,27 @@ import lombok.Value; * @author guqing * @since 2.0.0 */ -@Value -@Builder +@Data public class Stats { - Integer visit; + private Integer visit; - Integer upvote; + private Integer upvote; - Integer totalComment; + private Integer totalComment; - Integer approvedComment; + private Integer approvedComment; + + public Stats() { + } + + @Builder + public Stats(Integer visit, Integer upvote, Integer totalComment, Integer approvedComment) { + this.visit = visit; + this.upvote = upvote; + this.totalComment = totalComment; + this.approvedComment = approvedComment; + } public static Stats empty() { return Stats.builder() diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java new file mode 100644 index 000000000..a6986a54c --- /dev/null +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PostCounterReconciler.java @@ -0,0 +1,53 @@ +package run.halo.app.core.extension.reconciler; + +import static run.halo.app.extension.index.query.QueryFactory.startsWith; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.content.Post; +import run.halo.app.event.post.PostStatsChangedEvent; +import run.halo.app.extension.DefaultExtensionMatcher; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.router.selector.FieldSelector; +import run.halo.app.metrics.MeterUtils; + +@Component +@RequiredArgsConstructor +public class PostCounterReconciler implements Reconciler { + + private final ApplicationEventPublisher eventPublisher; + private final ExtensionClient client; + + @Override + public Result reconcile(Request request) { + if (!isSameAsPost(request.name())) { + return Result.doNotRetry(); + } + client.fetch(Counter.class, request.name()).ifPresent(counter -> { + eventPublisher.publishEvent(new PostStatsChangedEvent(this, counter)); + }); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + var extension = new Counter(); + return builder + .extension(extension) + .onAddMatcher(DefaultExtensionMatcher.builder(client, extension.groupVersionKind()) + .fieldSelector(FieldSelector.of( + startsWith("metadata.name", MeterUtils.nameOf(Post.class, ""))) + ) + .build()) + .build(); + } + + static boolean isSameAsPost(String name) { + return name.startsWith(MeterUtils.nameOf(Post.class, "")); + } +} diff --git a/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java new file mode 100644 index 000000000..aa936b821 --- /dev/null +++ b/application/src/main/java/run/halo/app/event/post/PostStatsChangedEvent.java @@ -0,0 +1,23 @@ +package run.halo.app.event.post; + +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationEvent; +import run.halo.app.core.extension.Counter; +import run.halo.app.core.extension.content.Post; +import run.halo.app.metrics.MeterUtils; + +@Getter +public class PostStatsChangedEvent extends ApplicationEvent { + private final Counter counter; + + public PostStatsChangedEvent(Object source, Counter counter) { + super(source); + this.counter = counter; + } + + public String getPostName() { + var counterName = counter.getMetadata().getName(); + return StringUtils.removeStart(counterName, MeterUtils.nameOf(Post.class, "")); + } +} diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index e2d1c692a..c5fdf462b 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -9,11 +9,13 @@ import static run.halo.app.extension.index.IndexAttributeFactory.simpleAttribute import com.fasterxml.jackson.core.type.TypeReference; import java.util.Set; import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.boot.context.event.ApplicationContextInitializedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; +import run.halo.app.content.Stats; import run.halo.app.core.extension.AnnotationSetting; import run.halo.app.core.extension.AuthProvider; import run.halo.app.core.extension.Counter; @@ -182,6 +184,30 @@ public class SchemeInitializer implements ApplicationListener { + var annotations = MetadataUtil.nullSafeAnnotations(post); + var statsStr = annotations.get(Post.STATS_ANNO); + if (StringUtils.isBlank(statsStr)) { + return "0"; + } + var stats = JsonUtils.jsonToObject(statsStr, Stats.class); + return ObjectUtils.defaultIfNull(stats.getVisit(), 0).toString(); + }))); + + indexSpecs.add(new IndexSpec() + .setName("stats.totalComment") + .setIndexFunc(simpleAttribute(Post.class, post -> { + var annotations = MetadataUtil.nullSafeAnnotations(post); + var statsStr = annotations.get(Post.STATS_ANNO); + if (StringUtils.isBlank(statsStr)) { + return "0"; + } + var stats = JsonUtils.jsonToObject(statsStr, Stats.class); + return ObjectUtils.defaultIfNull(stats.getTotalComment(), 0).toString(); + }))); }); schemeManager.register(Category.class, indexSpecs -> { indexSpecs.add(new IndexSpec() diff --git a/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java b/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java new file mode 100644 index 000000000..f417c6e1c --- /dev/null +++ b/application/src/main/java/run/halo/app/metrics/PostStatsUpdater.java @@ -0,0 +1,90 @@ +package run.halo.app.metrics; + +import java.time.Duration; +import java.time.Instant; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import run.halo.app.content.Stats; +import run.halo.app.core.extension.content.Post; +import run.halo.app.event.post.PostStatsChangedEvent; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.MetadataUtil; +import run.halo.app.extension.controller.Controller; +import run.halo.app.extension.controller.ControllerBuilder; +import run.halo.app.extension.controller.DefaultController; +import run.halo.app.extension.controller.DefaultQueue; +import run.halo.app.extension.controller.Reconciler; +import run.halo.app.extension.controller.RequestQueue; +import run.halo.app.infra.utils.JsonUtils; + +@Component +public class PostStatsUpdater implements Reconciler, + SmartLifecycle { + + private volatile boolean running = false; + + private final ExtensionClient client; + private final RequestQueue queue; + private final Controller controller; + + public PostStatsUpdater(ExtensionClient client) { + this.client = client; + queue = new DefaultQueue<>(Instant::now); + controller = this.setupWith(null); + } + + @Override + public Result reconcile(StatsRequest request) { + client.fetch(Post.class, request.postName()).ifPresent(post -> { + var annotations = MetadataUtil.nullSafeAnnotations(post); + annotations.put(Post.STATS_ANNO, JsonUtils.objectToJson(request.stats())); + client.update(post); + }); + return Result.doNotRetry(); + } + + @Override + public Controller setupWith(ControllerBuilder builder) { + return new DefaultController<>( + this.getClass().getName(), + this, + queue, + null, + Duration.ofMillis(100), + Duration.ofMinutes(10)); + } + + @Override + public void start() { + this.controller.start(); + this.running = true; + } + + @Override + public void stop() { + this.running = false; + this.controller.dispose(); + } + + @Override + public boolean isRunning() { + return this.running; + } + + @EventListener(PostStatsChangedEvent.class) + public void onReplyEvent(PostStatsChangedEvent event) { + var counter = event.getCounter(); + var stats = Stats.builder() + .visit(counter.getVisit()) + .upvote(counter.getUpvote()) + .totalComment(counter.getTotalComment()) + .approvedComment(counter.getApprovedComment()) + .build(); + var request = new StatsRequest(event.getPostName(), stats); + queue.addImmediately(request); + } + + public record StatsRequest(String postName, Stats stats) { + } +} diff --git a/ui/console-src/modules/contents/posts/PostList.vue b/ui/console-src/modules/contents/posts/PostList.vue index 6a51793ec..094584613 100644 --- a/ui/console-src/modules/contents/posts/PostList.vue +++ b/ui/console-src/modules/contents/posts/PostList.vue @@ -420,6 +420,14 @@ watch(selectedPostNames, (newValue) => { label: t('core.post.filters.sort.items.create_time_asc'), value: 'metadata.creationTimestamp,asc', }, + { + label: t('core.post.filters.sort.items.visit_desc'), + value: 'stats.visit,desc', + }, + { + label: t('core.post.filters.sort.items.comment_desc'), + value: 'stats.totalComment,desc', + }, ]" />
diff --git a/ui/src/locales/en.yaml b/ui/src/locales/en.yaml index 37cd51cf3..d39b96cc9 100644 --- a/ui/src/locales/en.yaml +++ b/ui/src/locales/en.yaml @@ -217,6 +217,8 @@ core: publish_time_asc: Earliest Published create_time_desc: Latest Created create_time_asc: Earliest Created + visit_desc: Most Visits + comment_desc: Most Comments list: fields: categories: "Categories:" diff --git a/ui/src/locales/es.yaml b/ui/src/locales/es.yaml index 28588bd14..ede364015 100644 --- a/ui/src/locales/es.yaml +++ b/ui/src/locales/es.yaml @@ -179,6 +179,8 @@ core: publish_time_asc: Publicado más antiguo create_time_desc: Creado más reciente create_time_asc: Creado más antiguo + visit_desc: Máximo de visitas + comment_desc: Número máximo de comentarios list: fields: categories: "Categorías:" diff --git a/ui/src/locales/zh-CN.yaml b/ui/src/locales/zh-CN.yaml index 56e73346a..1070143e1 100644 --- a/ui/src/locales/zh-CN.yaml +++ b/ui/src/locales/zh-CN.yaml @@ -209,6 +209,8 @@ core: publish_time_asc: 较早发布 create_time_desc: 较近创建 create_time_asc: 较早创建 + visit_desc: 最多访问量 + comment_desc: 最多评论量 list: fields: categories: 分类: diff --git a/ui/src/locales/zh-TW.yaml b/ui/src/locales/zh-TW.yaml index 2d41a1cee..b4ca7f4de 100644 --- a/ui/src/locales/zh-TW.yaml +++ b/ui/src/locales/zh-TW.yaml @@ -209,6 +209,8 @@ core: publish_time_asc: 較早發布 create_time_desc: 較近創建 create_time_asc: 較早創建 + visit_desc: 最多訪問量 + comment_desc: 最多評論量 list: fields: categories: 分類: