diff --git a/open-isle-cli/package-lock.json b/open-isle-cli/package-lock.json index eb65e16a2..e2e900c42 100644 --- a/open-isle-cli/package-lock.json +++ b/open-isle-cli/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "core-js": "^3.8.3", + "echarts": "^5.6.0", "ldrs": "^1.1.7", "markdown-it": "^14.1.0", "vditor": "^3.8.7", "vue": "^3.2.13", "vue-easy-lightbox": "^1.19.0", + "vue-echarts": "^7.0.3", "vue-router": "^4.5.1", "vue-toastification": "^2.0.0-rc.5" }, @@ -5225,6 +5227,22 @@ "node": ">=6.0.0" } }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", @@ -11344,6 +11362,32 @@ } } }, + "node_modules/vue-demi": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.13.11.tgz", + "integrity": "sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/vue-easy-lightbox": { "version": "1.19.0", "resolved": "https://registry.npmjs.org/vue-easy-lightbox/-/vue-easy-lightbox-1.19.0.tgz", @@ -11356,6 +11400,25 @@ "vue": "^3.0.0" } }, + "node_modules/vue-echarts": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/vue-echarts/-/vue-echarts-7.0.3.tgz", + "integrity": "sha512-/jSxNwOsw5+dYAUcwSfkLwKPuzTQ0Cepz1LxCOpj2QcHrrmUa/Ql0eQqMmc1rTPQVrh2JQ29n2dhq75ZcHvRDw==", + "license": "MIT", + "dependencies": { + "vue-demi": "^0.13.11" + }, + "peerDependencies": { + "@vue/runtime-core": "^3.0.0", + "echarts": "^5.5.1", + "vue": "^2.7.0 || ^3.1.1" + }, + "peerDependenciesMeta": { + "@vue/runtime-core": { + "optional": true + } + } + }, "node_modules/vue-eslint-parser": { "version": "8.3.0", "resolved": "https://registry.npmmirror.com/vue-eslint-parser/-/vue-eslint-parser-8.3.0.tgz", @@ -12272,6 +12335,21 @@ "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", "dev": true, "license": "ISC" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/open-isle-cli/package.json b/open-isle-cli/package.json index a1acfc457..979bcfd71 100644 --- a/open-isle-cli/package.json +++ b/open-isle-cli/package.json @@ -15,7 +15,9 @@ "vue": "^3.2.13", "vue-router": "^4.5.1", "vue-toastification": "^2.0.0-rc.5", - "vue-easy-lightbox": "^1.19.0" + "vue-easy-lightbox": "^1.19.0", + "echarts": "^5.6.0", + "vue-echarts": "^7.0.3" }, "devDependencies": { "@babel/core": "^7.12.16", diff --git a/open-isle-cli/src/components/MenuComponent.vue b/open-isle-cli/src/components/MenuComponent.vue index 98e0232c7..53bf75254 100644 --- a/open-isle-cli/src/components/MenuComponent.vue +++ b/open-isle-cli/src/components/MenuComponent.vue @@ -17,6 +17,15 @@ 关于 + + + 站点统计 + 发帖 @@ -109,6 +118,9 @@ export default { }, showUnreadCount() { return this.unreadCount > 99 ? '99+' : this.unreadCount + }, + shouldShowStats() { + return authState.role === 'ADMIN' } }, async mounted() { diff --git a/open-isle-cli/src/components/ReactionsGroup.vue b/open-isle-cli/src/components/ReactionsGroup.vue index 1feb6e6bb..c9e83bc51 100644 --- a/open-isle-cli/src/components/ReactionsGroup.vue +++ b/open-isle-cli/src/components/ReactionsGroup.vue @@ -41,7 +41,7 @@ const fetchTypes = async () => { try { const token = getToken() const res = await fetch(`${API_BASE_URL}/api/reaction-types`, { - headers: { Authorization: `Bearer ${token}` } + headers: { Authorization: token ? `Bearer ${token}` : '' } }) if (res.ok) { cachedTypes = await res.json() diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js index 2e8b8c30a..369473c35 100644 --- a/open-isle-cli/src/main.js +++ b/open-isle-cli/src/main.js @@ -9,14 +9,14 @@ import { checkToken, clearToken } from './utils/auth' import { initTheme } from './utils/theme' // Configurable API domain and port -// export const API_DOMAIN = 'http://127.0.0.1' -// export const API_PORT = 8081 +export const API_DOMAIN = 'http://127.0.0.1' +export const API_PORT = 8081 -export const API_DOMAIN = 'http://47.82.99.208' -export const API_PORT = 8080 +// export const API_DOMAIN = 'http://47.82.99.208' +// export const API_PORT = 8080 -// export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN -export const API_BASE_URL = ""; +export const API_BASE_URL = API_PORT ? `${API_DOMAIN}:${API_PORT}` : API_DOMAIN +// export const API_BASE_URL = ""; export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com' export const toast = useToast() diff --git a/open-isle-cli/src/router/index.js b/open-isle-cli/src/router/index.js index 0f4fd1e0a..a870d149c 100644 --- a/open-isle-cli/src/router/index.js +++ b/open-isle-cli/src/router/index.js @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import HomePageView from '../views/HomePageView.vue' import MessagePageView from '../views/MessagePageView.vue' import AboutPageView from '../views/AboutPageView.vue' +import SiteStatsPageView from '../views/SiteStatsPageView.vue' import PostPageView from '../views/PostPageView.vue' import LoginPageView from '../views/LoginPageView.vue' import SignupPageView from '../views/SignupPageView.vue' @@ -27,6 +28,11 @@ const routes = [ name: 'about', component: AboutPageView }, + { + path: '/about/stats', + name: 'site-stats', + component: SiteStatsPageView + }, { path: '/new-post', name: 'new-post', diff --git a/open-isle-cli/src/views/AboutPageView.vue b/open-isle-cli/src/views/AboutPageView.vue index 04eec446f..35bb0c3a9 100644 --- a/open-isle-cli/src/views/AboutPageView.vue +++ b/open-isle-cli/src/views/AboutPageView.vue @@ -60,11 +60,10 @@ export default { diff --git a/open-isle-cli/src/views/SiteStatsPageView.vue b/open-isle-cli/src/views/SiteStatsPageView.vue new file mode 100644 index 000000000..8c420775a --- /dev/null +++ b/open-isle-cli/src/views/SiteStatsPageView.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java index 98f9ad25b..0a769fc35 100644 --- a/src/main/java/com/openisle/config/SecurityConfig.java +++ b/src/main/java/com/openisle/config/SecurityConfig.java @@ -1,6 +1,7 @@ package com.openisle.config; import com.openisle.service.JwtService; +import com.openisle.service.UserVisitService; import com.openisle.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -38,6 +39,7 @@ public class SecurityConfig { private final JwtService jwtService; private final UserRepository userRepository; private final AccessDeniedHandler customAccessDeniedHandler; + private final UserVisitService userVisitService; @Bean public PasswordEncoder passwordEncoder() { @@ -96,6 +98,7 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/tags/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/search/**").permitAll() .requestMatchers(HttpMethod.GET, "/api/users/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/reaction-types").permitAll() .requestMatchers(HttpMethod.POST, "/api/categories/**").hasAuthority("ADMIN") .requestMatchers(HttpMethod.POST, "/api/tags/**").authenticated() .requestMatchers(HttpMethod.DELETE, "/api/categories/**").hasAuthority("ADMIN") @@ -103,7 +106,8 @@ public class SecurityConfig { .requestMatchers("/api/admin/**").hasAuthority("ADMIN") .anyRequest().authenticated() ) - .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(userVisitFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -123,7 +127,8 @@ public class SecurityConfig { boolean publicGet = "GET".equalsIgnoreCase(request.getMethod()) && (uri.startsWith("/api/posts") || uri.startsWith("/api/comments") || uri.startsWith("/api/categories") || uri.startsWith("/api/tags") || - uri.startsWith("/api/search") || uri.startsWith("/api/users")); + uri.startsWith("/api/search") || uri.startsWith("/api/users") || + uri.startsWith("/api/reaction-types")); if (authHeader != null && authHeader.startsWith("Bearer ")) { String token = authHeader.substring(7); @@ -150,4 +155,18 @@ public class SecurityConfig { } }; } + + @Bean + public OncePerRequestFilter userVisitFilter() { + return new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + var auth = org.springframework.security.core.context.SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !(auth instanceof org.springframework.security.authentication.AnonymousAuthenticationToken)) { + userVisitService.recordVisit(auth.getName()); + } + filterChain.doFilter(request, response); + } + }; + } } diff --git a/src/main/java/com/openisle/controller/StatController.java b/src/main/java/com/openisle/controller/StatController.java new file mode 100644 index 000000000..95abdf577 --- /dev/null +++ b/src/main/java/com/openisle/controller/StatController.java @@ -0,0 +1,41 @@ +package com.openisle.controller; + +import com.openisle.service.UserVisitService; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/stats") +@RequiredArgsConstructor +public class StatController { + private final UserVisitService userVisitService; + + @GetMapping("/dau") + public Map dau(@RequestParam(value = "date", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + long count = userVisitService.countDau(date); + return Map.of("dau", count); + } + + @GetMapping("/dau-range") + public List> dauRange(@RequestParam(value = "days", defaultValue = "30") int days) { + if (days < 1) days = 1; + LocalDate end = LocalDate.now(); + LocalDate start = end.minusDays(days - 1L); + var data = userVisitService.countDauRange(start, end); + return data.entrySet().stream() + .map(e -> Map.of( + "date", e.getKey().toString(), + "value", e.getValue() + )) + .toList(); + } +} diff --git a/src/main/java/com/openisle/repository/UserVisitRepository.java b/src/main/java/com/openisle/repository/UserVisitRepository.java index bf5870fbc..afcff1885 100644 --- a/src/main/java/com/openisle/repository/UserVisitRepository.java +++ b/src/main/java/com/openisle/repository/UserVisitRepository.java @@ -3,6 +3,8 @@ package com.openisle.repository; import com.openisle.model.User; import com.openisle.model.UserVisit; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDate; import java.util.Optional; @@ -10,4 +12,8 @@ import java.util.Optional; public interface UserVisitRepository extends JpaRepository { Optional findByUserAndVisitDate(User user, LocalDate visitDate); long countByUser(User user); + long countByVisitDate(LocalDate visitDate); + + @Query("SELECT uv.visitDate AS d, COUNT(uv) AS c FROM UserVisit uv WHERE uv.visitDate BETWEEN :start AND :end GROUP BY uv.visitDate ORDER BY uv.visitDate") + java.util.List countRange(@Param("start") LocalDate start, @Param("end") LocalDate end); } diff --git a/src/main/java/com/openisle/service/UserVisitService.java b/src/main/java/com/openisle/service/UserVisitService.java index 7ae912851..54ab2db35 100644 --- a/src/main/java/com/openisle/service/UserVisitService.java +++ b/src/main/java/com/openisle/service/UserVisitService.java @@ -8,6 +8,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.Map; @Service @RequiredArgsConstructor @@ -32,4 +34,27 @@ public class UserVisitService { .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); return userVisitRepository.countByUser(user); } + + public long countDau(LocalDate date) { + LocalDate d = date != null ? date : LocalDate.now(); + return userVisitRepository.countByVisitDate(d); + } + + public Map countDauRange(LocalDate start, LocalDate end) { + Map result = new LinkedHashMap<>(); + if (start == null || end == null || start.isAfter(end)) { + return result; + } + var list = userVisitRepository.countRange(start, end); + for (var obj : list) { + LocalDate d = (LocalDate) obj[0]; + Long c = (Long) obj[1]; + result.put(d, c); + } + // fill zero counts for missing dates + for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) { + result.putIfAbsent(d, 0L); + } + return result; + } } diff --git a/src/test/java/com/openisle/controller/AdminControllerTest.java b/src/test/java/com/openisle/controller/AdminControllerTest.java index 587f50f41..8b068e415 100644 --- a/src/test/java/com/openisle/controller/AdminControllerTest.java +++ b/src/test/java/com/openisle/controller/AdminControllerTest.java @@ -11,6 +11,7 @@ import com.openisle.config.CustomAccessDeniedHandler; import com.openisle.config.SecurityConfig; import com.openisle.service.JwtService; import com.openisle.repository.UserRepository; +import com.openisle.service.UserVisitService; import com.openisle.model.Role; import com.openisle.model.User; import java.util.Optional; @@ -31,6 +32,8 @@ class AdminControllerTest { private JwtService jwtService; @MockBean private UserRepository userRepository; + @MockBean + private UserVisitService userVisitService; @Test void adminHelloReturnsMessage() throws Exception { diff --git a/src/test/java/com/openisle/controller/HelloControllerTest.java b/src/test/java/com/openisle/controller/HelloControllerTest.java index 65ab22779..d8837feba 100644 --- a/src/test/java/com/openisle/controller/HelloControllerTest.java +++ b/src/test/java/com/openisle/controller/HelloControllerTest.java @@ -11,6 +11,7 @@ import com.openisle.config.CustomAccessDeniedHandler; import com.openisle.config.SecurityConfig; import com.openisle.service.JwtService; import com.openisle.repository.UserRepository; +import com.openisle.service.UserVisitService; import com.openisle.model.Role; import com.openisle.model.User; import java.util.Optional; @@ -31,6 +32,8 @@ class HelloControllerTest { private JwtService jwtService; @MockBean private UserRepository userRepository; + @MockBean + private UserVisitService userVisitService; @Test void helloReturnsMessage() throws Exception { diff --git a/src/test/java/com/openisle/controller/StatControllerTest.java b/src/test/java/com/openisle/controller/StatControllerTest.java new file mode 100644 index 000000000..cdbcd05ae --- /dev/null +++ b/src/test/java/com/openisle/controller/StatControllerTest.java @@ -0,0 +1,74 @@ +package com.openisle.controller; + +import com.openisle.config.CustomAccessDeniedHandler; +import com.openisle.config.SecurityConfig; +import com.openisle.service.JwtService; +import com.openisle.repository.UserRepository; +import com.openisle.service.UserVisitService; +import com.openisle.model.Role; +import com.openisle.model.User; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(StatController.class) +@AutoConfigureMockMvc +@Import({SecurityConfig.class, CustomAccessDeniedHandler.class}) +class StatControllerTest { + @Autowired + private MockMvc mockMvc; + + @MockBean + private JwtService jwtService; + @MockBean + private UserRepository userRepository; + @MockBean + private UserVisitService userVisitService; + + @Test + void dauReturnsCount() 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)); + Mockito.when(userVisitService.countDau(Mockito.any())).thenReturn(3L); + + mockMvc.perform(get("/api/stats/dau").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.dau").value(3)); + } + + @Test + void dauRangeReturnsSeries() 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), 1L); + map.put(java.time.LocalDate.now(), 2L); + Mockito.when(userVisitService.countDauRange(Mockito.any(), Mockito.any())).thenReturn(map); + + mockMvc.perform(get("/api/stats/dau-range").param("days", "2").header("Authorization", "Bearer token")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].value").value(1)) + .andExpect(jsonPath("$[1].value").value(2)); + } +}