diff --git a/backend/src/main/java/com/openisle/controller/StatController.java b/backend/src/main/java/com/openisle/controller/StatController.java index 95abdf577..80701c3ec 100644 --- a/backend/src/main/java/com/openisle/controller/StatController.java +++ b/backend/src/main/java/com/openisle/controller/StatController.java @@ -1,6 +1,7 @@ package com.openisle.controller; import com.openisle.service.UserVisitService; +import com.openisle.service.StatService; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; @@ -17,6 +18,7 @@ import java.util.Map; @RequiredArgsConstructor public class StatController { private final UserVisitService userVisitService; + private final StatService statService; @GetMapping("/dau") public Map dau(@RequestParam(value = "date", required = false) @@ -38,4 +40,46 @@ public class StatController { )) .toList(); } + + @GetMapping("/new-users-range") + public List> newUsersRange(@RequestParam(value = "days", defaultValue = "30") int days) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countNewUsersRange(start, end); + return data.entrySet().stream() + .map(e -> Map.of( + "date", e.getKey().toString(), + "value", e.getValue() + )) + .toList(); + } + + @GetMapping("/posts-range") + public List> postsRange(@RequestParam(value = "days", defaultValue = "30") int days) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countPostsRange(start, end); + return data.entrySet().stream() + .map(e -> Map.of( + "date", e.getKey().toString(), + "value", e.getValue() + )) + .toList(); + } + + @GetMapping("/comments-range") + public List> commentsRange(@RequestParam(value = "days", defaultValue = "30") int days) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = statService.countCommentsRange(start, end); + return data.entrySet().stream() + .map(e -> Map.of( + "date", e.getKey().toString(), + "value", e.getValue() + )) + .toList(); + } } diff --git a/backend/src/main/java/com/openisle/repository/CommentRepository.java b/backend/src/main/java/com/openisle/repository/CommentRepository.java index de65699cd..c9c296bd4 100644 --- a/backend/src/main/java/com/openisle/repository/CommentRepository.java +++ b/backend/src/main/java/com/openisle/repository/CommentRepository.java @@ -32,4 +32,8 @@ public interface CommentRepository extends JpaRepository { long countByAuthor_Id(Long userId); + @org.springframework.data.jpa.repository.Query("SELECT FUNCTION('date', c.createdAt) AS d, COUNT(c) AS c FROM Comment c " + + "WHERE c.createdAt >= :start AND c.createdAt < :end GROUP BY d ORDER BY d") + java.util.List countDailyRange(@org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start, + @org.springframework.data.repository.query.Param("end") java.time.LocalDateTime end); } diff --git a/backend/src/main/java/com/openisle/repository/PostRepository.java b/backend/src/main/java/com/openisle/repository/PostRepository.java index 1c238db75..df0905792 100644 --- a/backend/src/main/java/com/openisle/repository/PostRepository.java +++ b/backend/src/main/java/com/openisle/repository/PostRepository.java @@ -95,4 +95,9 @@ public interface PostRepository extends JpaRepository { long countDistinctByTags_Id(Long tagId); long countByAuthor_Id(Long userId); + + @Query("SELECT FUNCTION('date', p.createdAt) AS d, COUNT(p) AS c FROM Post p " + + "WHERE p.createdAt >= :start AND p.createdAt < :end GROUP BY d ORDER BY d") + java.util.List countDailyRange(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/backend/src/main/java/com/openisle/repository/UserRepository.java b/backend/src/main/java/com/openisle/repository/UserRepository.java index 64b7c88ee..35847fb55 100644 --- a/backend/src/main/java/com/openisle/repository/UserRepository.java +++ b/backend/src/main/java/com/openisle/repository/UserRepository.java @@ -1,6 +1,8 @@ package com.openisle.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.openisle.model.User; import java.time.LocalDateTime; import java.util.Optional; @@ -12,4 +14,9 @@ public interface UserRepository extends JpaRepository { java.util.List findByRole(com.openisle.model.Role role); long countByExperienceGreaterThanEqual(int experience); long countByCreatedAtBefore(LocalDateTime createdAt); + + @Query("SELECT FUNCTION('date', u.createdAt) AS d, COUNT(u) AS c FROM User u " + + "WHERE u.createdAt >= :start AND u.createdAt < :end GROUP BY d ORDER BY d") + java.util.List countDailyRange(@Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/backend/src/main/java/com/openisle/service/StatService.java b/backend/src/main/java/com/openisle/service/StatService.java new file mode 100644 index 000000000..758967204 --- /dev/null +++ b/backend/src/main/java/com/openisle/service/StatService.java @@ -0,0 +1,48 @@ +package com.openisle.service; + +import com.openisle.repository.UserRepository; +import com.openisle.repository.PostRepository; +import com.openisle.repository.CommentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class StatService { + private final UserRepository userRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + + private Map toDateMap(LocalDate start, LocalDate end, java.util.List list) { + Map result = new LinkedHashMap<>(); + for (var obj : list) { + LocalDate d = (LocalDate) obj[0]; + Long c = ((Number) obj[1]).longValue(); + result.put(d, c); + } + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + result.putIfAbsent(d, 0L); + } + return result; + } + + public Map countNewUsersRange(LocalDate start, LocalDate end) { + java.util.List list = userRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); + return toDateMap(start, end, list); + } + + public Map countPostsRange(LocalDate start, LocalDate end) { + java.util.List list = postRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); + return toDateMap(start, end, list); + } + + public Map countCommentsRange(LocalDate start, LocalDate end) { + java.util.List list = commentRepository.countDailyRange(start.atStartOfDay(), end.plusDays(1).atStartOfDay()); + return toDateMap(start, end, list); + } +} + diff --git a/backend/src/test/java/com/openisle/controller/StatControllerTest.java b/backend/src/test/java/com/openisle/controller/StatControllerTest.java index cdbcd05ae..79948f257 100644 --- a/backend/src/test/java/com/openisle/controller/StatControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/StatControllerTest.java @@ -5,6 +5,7 @@ import com.openisle.config.SecurityConfig; import com.openisle.service.JwtService; import com.openisle.repository.UserRepository; import com.openisle.service.UserVisitService; +import com.openisle.service.StatService; import com.openisle.model.Role; import com.openisle.model.User; import org.junit.jupiter.api.Test; @@ -35,6 +36,8 @@ class StatControllerTest { private UserRepository userRepository; @MockBean private UserVisitService userVisitService; + @MockBean + private StatService statService; @Test void dauReturnsCount() throws Exception { @@ -71,4 +74,64 @@ class StatControllerTest { .andExpect(jsonPath("$[0].value").value(1)) .andExpect(jsonPath("$[1].value").value(2)); } + + @Test + void newUsersRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 5L); + map.put(java.time.LocalDate.now(), 6L); + Mockito.when(statService.countNewUsersRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc.perform(get("/api/stats/new-users-range").param("days", "2").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(5)) + .andExpect(jsonPath("$[1].value").value(6)); + } + + @Test + void postsRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 7L); + map.put(java.time.LocalDate.now(), 8L); + Mockito.when(statService.countPostsRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc.perform(get("/api/stats/posts-range").param("days", "2").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(7)) + .andExpect(jsonPath("$[1].value").value(8)); + } + + @Test + void commentsRangeReturnsSeries() throws Exception { + Mockito.when(jwtService.validateAndGetSubject("token")).thenReturn("user"); + User user = new User(); + user.setUsername("user"); + user.setPassword("p"); + user.setEmail("u@example.com"); + user.setRole(Role.USER); + Mockito.when(userRepository.findByUsername("user")).thenReturn(Optional.of(user)); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put(java.time.LocalDate.now().minusDays(1), 9L); + map.put(java.time.LocalDate.now(), 10L); + Mockito.when(statService.countCommentsRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc.perform(get("/api/stats/comments-range").param("days", "2").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(9)) + .andExpect(jsonPath("$[1].value").value(10)); + } } diff --git a/frontend_nuxt/pages/about/stats.vue b/frontend_nuxt/pages/about/stats.vue index 805ee0894..4b4e3402e 100644 --- a/frontend_nuxt/pages/about/stats.vue +++ b/frontend_nuxt/pages/about/stats.vue @@ -1,7 +1,30 @@ @@ -23,20 +46,28 @@ import { getToken } from '../utils/auth' use([LineChart, TitleComponent, TooltipComponent, GridComponent, DataZoomComponent, CanvasRenderer]) -const option = ref(null) +const dauOption = ref(null) +const newUserOption = ref(null) +const postOption = ref(null) +const commentOption = ref(null) async function loadData() { const token = getToken() - const res = await fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, { - headers: { Authorization: `Bearer ${token}` }, - }) - if (res.ok) { - const data = await res.json() + const headers = { Authorization: `Bearer ${token}` } + + const [dauRes, newUserRes, postRes, commentRes] = await Promise.all([ + fetch(`${API_BASE_URL}/api/stats/dau-range?days=30`, { headers }), + fetch(`${API_BASE_URL}/api/stats/new-users-range?days=30`, { headers }), + fetch(`${API_BASE_URL}/api/stats/posts-range?days=30`, { headers }), + fetch(`${API_BASE_URL}/api/stats/comments-range?days=30`, { headers }), + ]) + + function toOption(title, data) { data.sort((a, b) => new Date(a.date) - new Date(b.date)) const dates = data.map((d) => d.date) const values = data.map((d) => d.value) - option.value = { - title: { text: '站点 DAU' }, + return { + title: { text: title }, tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: dates }, yAxis: { type: 'value' }, @@ -44,6 +75,23 @@ async function loadData() { series: [{ type: 'line', areaStyle: {}, smooth: true, data: values }], } } + + if (dauRes.ok) { + const data = await dauRes.json() + dauOption.value = toOption('站点 DAU', data) + } + if (newUserRes.ok) { + const data = await newUserRes.json() + newUserOption.value = toOption('每日新增用户', data) + } + if (postRes.ok) { + const data = await postRes.json() + postOption.value = toOption('每日发帖量', data) + } + if (commentRes.ok) { + const data = await commentRes.json() + commentOption.value = toOption('每日回贴量', data) + } } onMounted(loadData)