From f42f277cb3ea96d893fc4c3fd4ac42c09d4632f2 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:21:50 +0800 Subject: [PATCH 1/7] Add DAU statistics module --- .../com/openisle/config/SecurityConfig.java | 19 ++++++- .../openisle/controller/StatController.java | 26 +++++++++ .../repository/UserVisitRepository.java | 1 + .../openisle/service/UserVisitService.java | 5 ++ .../controller/AdminControllerTest.java | 3 ++ .../controller/HelloControllerTest.java | 3 ++ .../controller/StatControllerTest.java | 54 +++++++++++++++++++ 7 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/openisle/controller/StatController.java create mode 100644 src/test/java/com/openisle/controller/StatControllerTest.java diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java index 98f9ad25b..2dd18b7ce 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() { @@ -103,7 +105,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(); } @@ -150,4 +153,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..0e05ca686 --- /dev/null +++ b/src/main/java/com/openisle/controller/StatController.java @@ -0,0 +1,26 @@ +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.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); + } +} diff --git a/src/main/java/com/openisle/repository/UserVisitRepository.java b/src/main/java/com/openisle/repository/UserVisitRepository.java index bf5870fbc..9f05ecbeb 100644 --- a/src/main/java/com/openisle/repository/UserVisitRepository.java +++ b/src/main/java/com/openisle/repository/UserVisitRepository.java @@ -10,4 +10,5 @@ import java.util.Optional; public interface UserVisitRepository extends JpaRepository { Optional findByUserAndVisitDate(User user, LocalDate visitDate); long countByUser(User user); + long countByVisitDate(LocalDate visitDate); } diff --git a/src/main/java/com/openisle/service/UserVisitService.java b/src/main/java/com/openisle/service/UserVisitService.java index 7ae912851..29c5cdefe 100644 --- a/src/main/java/com/openisle/service/UserVisitService.java +++ b/src/main/java/com/openisle/service/UserVisitService.java @@ -32,4 +32,9 @@ 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); + } } 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..a44df193f --- /dev/null +++ b/src/test/java/com/openisle/controller/StatControllerTest.java @@ -0,0 +1,54 @@ +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)); + } +} From 53c878d11da22bfbaa24007566f07f914cb2a748 Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 14 Jul 2025 21:26:25 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E5=85=B3?= =?UTF-8?q?=E4=BA=8E=E9=A1=B5=E9=9D=A2ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- open-isle-cli/src/views/AboutPageView.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 { From 609156f4e5a15b93e53df4030ed8a74a2565d60f Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:32:07 +0800 Subject: [PATCH 3/7] feat: add whitelist register mode --- .../controller/AdminConfigController.java | 8 +++++ .../controller/AdminUserController.java | 36 +++++++++++++++++++ .../openisle/controller/AuthController.java | 13 +++++-- .../openisle/controller/ConfigController.java | 7 ++++ .../java/com/openisle/model/Notification.java | 3 ++ .../com/openisle/model/NotificationType.java | 4 ++- .../java/com/openisle/model/RegisterMode.java | 9 +++++ src/main/java/com/openisle/model/User.java | 6 ++++ .../com/openisle/service/CommentService.java | 14 ++++---- .../openisle/service/GoogleAuthService.java | 8 +++-- .../openisle/service/NotificationService.java | 5 +-- .../com/openisle/service/PostService.java | 13 +++---- .../com/openisle/service/ReactionService.java | 4 +-- .../openisle/service/RegisterModeService.java | 25 +++++++++++++ .../openisle/service/SubscriptionService.java | 8 ++--- .../com/openisle/service/UserService.java | 9 ++++- src/main/resources/application.properties | 3 ++ .../controller/AuthControllerTest.java | 8 +++-- .../ComplexFlowIntegrationTest.java | 5 +-- .../PublishModeIntegrationTest.java | 4 +-- .../integration/SearchIntegrationTest.java | 5 +-- 21 files changed, 161 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/openisle/controller/AdminUserController.java create mode 100644 src/main/java/com/openisle/model/RegisterMode.java create mode 100644 src/main/java/com/openisle/service/RegisterModeService.java diff --git a/src/main/java/com/openisle/controller/AdminConfigController.java b/src/main/java/com/openisle/controller/AdminConfigController.java index b874a1a8d..97fd4744d 100644 --- a/src/main/java/com/openisle/controller/AdminConfigController.java +++ b/src/main/java/com/openisle/controller/AdminConfigController.java @@ -5,6 +5,8 @@ import com.openisle.model.PublishMode; import com.openisle.service.PasswordValidator; import com.openisle.service.PostService; import com.openisle.service.AiUsageService; +import com.openisle.service.RegisterModeService; +import com.openisle.model.RegisterMode; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -16,6 +18,7 @@ public class AdminConfigController { private final PostService postService; private final PasswordValidator passwordValidator; private final AiUsageService aiUsageService; + private final RegisterModeService registerModeService; @GetMapping public ConfigDto getConfig() { @@ -23,6 +26,7 @@ public class AdminConfigController { dto.setPublishMode(postService.getPublishMode()); dto.setPasswordStrength(passwordValidator.getStrength()); dto.setAiFormatLimit(aiUsageService.getFormatLimit()); + dto.setRegisterMode(registerModeService.getRegisterMode()); return dto; } @@ -37,6 +41,9 @@ public class AdminConfigController { if (dto.getAiFormatLimit() != null) { aiUsageService.setFormatLimit(dto.getAiFormatLimit()); } + if (dto.getRegisterMode() != null) { + registerModeService.setRegisterMode(dto.getRegisterMode()); + } return getConfig(); } @@ -45,5 +52,6 @@ public class AdminConfigController { private PublishMode publishMode; private PasswordStrength passwordStrength; private Integer aiFormatLimit; + private RegisterMode registerMode; } } diff --git a/src/main/java/com/openisle/controller/AdminUserController.java b/src/main/java/com/openisle/controller/AdminUserController.java new file mode 100644 index 000000000..6fa878718 --- /dev/null +++ b/src/main/java/com/openisle/controller/AdminUserController.java @@ -0,0 +1,36 @@ +package com.openisle.controller; + +import com.openisle.model.User; +import com.openisle.service.EmailSender; +import com.openisle.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/admin/users") +@RequiredArgsConstructor +public class AdminUserController { + private final UserRepository userRepository; + private final EmailSender emailSender; + + @PostMapping("/{id}/approve") + public ResponseEntity approve(@PathVariable Long id) { + User user = userRepository.findById(id).orElseThrow(); + user.setApproved(true); + userRepository.save(user); + emailSender.sendEmail(user.getEmail(), "Registration Approved", + "Your account has been approved. Visit: https://www.open-isle.com"); + return ResponseEntity.ok().build(); + } + + @PostMapping("/{id}/reject") + public ResponseEntity reject(@PathVariable Long id) { + User user = userRepository.findById(id).orElseThrow(); + user.setApproved(false); + userRepository.save(user); + emailSender.sendEmail(user.getEmail(), "Registration Rejected", + "Your account request was rejected. Visit: https://www.open-isle.com"); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/openisle/controller/AuthController.java b/src/main/java/com/openisle/controller/AuthController.java index c3abd2ae8..cf9483456 100644 --- a/src/main/java/com/openisle/controller/AuthController.java +++ b/src/main/java/com/openisle/controller/AuthController.java @@ -6,6 +6,8 @@ import com.openisle.service.JwtService; import com.openisle.service.UserService; import com.openisle.service.CaptchaService; import com.openisle.service.GoogleAuthService; +import com.openisle.service.RegisterModeService; +import com.openisle.model.RegisterMode; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -23,6 +25,7 @@ public class AuthController { private final EmailSender emailService; private final CaptchaService captchaService; private final GoogleAuthService googleAuthService; + private final RegisterModeService registerModeService; @Value("${app.captcha.enabled:false}") private boolean captchaEnabled; @@ -38,7 +41,8 @@ public class AuthController { if (captchaEnabled && registerCaptchaEnabled && !captchaService.verify(req.getCaptcha())) { return ResponseEntity.badRequest().body(Map.of("error", "Invalid captcha")); } - User user = userService.register(req.getUsername(), req.getEmail(), req.getPassword()); + User user = userService.register( + req.getUsername(), req.getEmail(), req.getPassword(), req.getReason(), registerModeService.getRegisterMode()); emailService.sendEmail(user.getEmail(), "Verification Code", "Your verification code is " + user.getVerificationCode()); return ResponseEntity.ok(Map.of("message", "Verification code sent")); } @@ -67,8 +71,11 @@ public class AuthController { @PostMapping("/google") public ResponseEntity loginWithGoogle(@RequestBody GoogleLoginRequest req) { - Optional user = googleAuthService.authenticate(req.getIdToken()); + Optional user = googleAuthService.authenticate(req.getIdToken(), req.getReason(), registerModeService.getRegisterMode()); if (user.isPresent()) { + if (!user.get().isApproved()) { + return ResponseEntity.badRequest().body(Map.of("error", "Account awaiting approval")); + } return ResponseEntity.ok(Map.of("token", jwtService.generateToken(user.get().getUsername()))); } return ResponseEntity.badRequest().body(Map.of("error", "Invalid google token")); @@ -85,6 +92,7 @@ public class AuthController { private String email; private String password; private String captcha; + private String reason; } @Data @@ -97,6 +105,7 @@ public class AuthController { @Data private static class GoogleLoginRequest { private String idToken; + private String reason; } @Data diff --git a/src/main/java/com/openisle/controller/ConfigController.java b/src/main/java/com/openisle/controller/ConfigController.java index d2164a2bb..a754877eb 100644 --- a/src/main/java/com/openisle/controller/ConfigController.java +++ b/src/main/java/com/openisle/controller/ConfigController.java @@ -2,12 +2,15 @@ package com.openisle.controller; import lombok.Data; import org.springframework.beans.factory.annotation.Value; +import com.openisle.service.RegisterModeService; +import com.openisle.model.RegisterMode; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api") +@lombok.RequiredArgsConstructor public class ConfigController { @Value("${app.captcha.enabled:false}") @@ -28,6 +31,8 @@ public class ConfigController { @Value("${app.ai.format-limit:3}") private int aiFormatLimit; + private final RegisterModeService registerModeService; + @GetMapping("/config") public ConfigResponse getConfig() { ConfigResponse resp = new ConfigResponse(); @@ -37,6 +42,7 @@ public class ConfigController { resp.setPostCaptchaEnabled(postCaptchaEnabled); resp.setCommentCaptchaEnabled(commentCaptchaEnabled); resp.setAiFormatLimit(aiFormatLimit); + resp.setRegisterMode(registerModeService.getRegisterMode()); return resp; } @@ -48,5 +54,6 @@ public class ConfigController { private boolean postCaptchaEnabled; private boolean commentCaptchaEnabled; private int aiFormatLimit; + private RegisterMode registerMode; } } diff --git a/src/main/java/com/openisle/model/Notification.java b/src/main/java/com/openisle/model/Notification.java index fa1f2f6c3..202bf2e30 100644 --- a/src/main/java/com/openisle/model/Notification.java +++ b/src/main/java/com/openisle/model/Notification.java @@ -45,6 +45,9 @@ public class Notification { @Column(name = "reaction_type") private ReactionType reactionType; + @Column(length = 1000) + private String content; + @Column private Boolean approved; diff --git a/src/main/java/com/openisle/model/NotificationType.java b/src/main/java/com/openisle/model/NotificationType.java index e68dfca9c..a08e3a286 100644 --- a/src/main/java/com/openisle/model/NotificationType.java +++ b/src/main/java/com/openisle/model/NotificationType.java @@ -27,5 +27,7 @@ public enum NotificationType { /** Someone unfollowed you */ USER_UNFOLLOWED, /** A user you subscribe to created a post or comment */ - USER_ACTIVITY + USER_ACTIVITY, + /** A user requested registration approval */ + REGISTER_REQUEST } diff --git a/src/main/java/com/openisle/model/RegisterMode.java b/src/main/java/com/openisle/model/RegisterMode.java new file mode 100644 index 000000000..d8c8fd2e4 --- /dev/null +++ b/src/main/java/com/openisle/model/RegisterMode.java @@ -0,0 +1,9 @@ +package com.openisle.model; + +/** + * Application-wide user registration mode. + */ +public enum RegisterMode { + DIRECT, + WHITELIST +} diff --git a/src/main/java/com/openisle/model/User.java b/src/main/java/com/openisle/model/User.java index 0d51ada45..dcc8516bb 100644 --- a/src/main/java/com/openisle/model/User.java +++ b/src/main/java/com/openisle/model/User.java @@ -42,6 +42,12 @@ public class User { @Column(length = 1000) private String introduction; + @Column(length = 1000) + private String registerReason; + + @Column(nullable = false) + private boolean approved = true; + @Enumerated(EnumType.STRING) @Column(nullable = false) private Role role = Role.USER; diff --git a/src/main/java/com/openisle/service/CommentService.java b/src/main/java/com/openisle/service/CommentService.java index 77780a630..0fc74d272 100644 --- a/src/main/java/com/openisle/service/CommentService.java +++ b/src/main/java/com/openisle/service/CommentService.java @@ -43,16 +43,16 @@ public class CommentService { comment.setContent(content); comment = commentRepository.save(comment); if (!author.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null); + notificationService.createNotification(post.getAuthor(), NotificationType.COMMENT_REPLY, post, comment, null, null, null, null); } for (User u : subscriptionService.getPostSubscribers(postId)) { if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null); + notificationService.createNotification(u, NotificationType.POST_UPDATED, post, comment, null, null, null, null); } } for (User u : subscriptionService.getSubscribers(author.getUsername())) { if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null); + notificationService.createNotification(u, NotificationType.USER_ACTIVITY, post, comment, null, null, null, null); } } return comment; @@ -70,21 +70,21 @@ public class CommentService { comment.setContent(content); comment = commentRepository.save(comment); if (!author.getId().equals(parent.getAuthor().getId())) { - notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null); + notificationService.createNotification(parent.getAuthor(), NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null); } for (User u : subscriptionService.getCommentSubscribers(parentId)) { if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null); + notificationService.createNotification(u, NotificationType.COMMENT_REPLY, parent.getPost(), comment, null, null, null, null); } } for (User u : subscriptionService.getPostSubscribers(parent.getPost().getId())) { if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null); + notificationService.createNotification(u, NotificationType.POST_UPDATED, parent.getPost(), comment, null, null, null, null); } } for (User u : subscriptionService.getSubscribers(author.getUsername())) { if (!u.getId().equals(author.getId())) { - notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null); + notificationService.createNotification(u, NotificationType.USER_ACTIVITY, parent.getPost(), comment, null, null, null, null); } } return comment; diff --git a/src/main/java/com/openisle/service/GoogleAuthService.java b/src/main/java/com/openisle/service/GoogleAuthService.java index 8ae4d24a6..0cdf80953 100644 --- a/src/main/java/com/openisle/service/GoogleAuthService.java +++ b/src/main/java/com/openisle/service/GoogleAuthService.java @@ -23,7 +23,7 @@ public class GoogleAuthService { @Value("${google.client-id:}") private String clientId; - public Optional authenticate(String idTokenString) { + public Optional authenticate(String idTokenString, String reason, com.openisle.model.RegisterMode mode) { GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new JacksonFactory()) .setAudience(Collections.singletonList(clientId)) .build(); @@ -35,13 +35,13 @@ public class GoogleAuthService { GoogleIdToken.Payload payload = idToken.getPayload(); String email = payload.getEmail(); String name = (String) payload.get("name"); - return Optional.of(processUser(email, name)); + return Optional.of(processUser(email, name, reason, mode)); } catch (Exception e) { return Optional.empty(); } } - private User processUser(String email, String name) { + private User processUser(String email, String name, String reason, com.openisle.model.RegisterMode mode) { Optional existing = userRepository.findByEmail(email); if (existing.isPresent()) { User user = existing.get(); @@ -64,6 +64,8 @@ public class GoogleAuthService { user.setPassword(""); user.setRole(Role.USER); user.setVerified(true); + user.setRegisterReason(reason); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); user.setAvatar("https://github.com/identicons/" + username + ".png"); return userRepository.save(user); } diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java index 1859ea2f0..93f50859a 100644 --- a/src/main/java/com/openisle/service/NotificationService.java +++ b/src/main/java/com/openisle/service/NotificationService.java @@ -16,11 +16,11 @@ public class NotificationService { private final UserRepository userRepository; public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) { - return createNotification(user, type, post, comment, approved, null, null); + return createNotification(user, type, post, comment, approved, null, null, null); } public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved, - User fromUser, ReactionType reactionType) { + User fromUser, ReactionType reactionType, String content) { Notification n = new Notification(); n.setUser(user); n.setType(type); @@ -29,6 +29,7 @@ public class NotificationService { n.setApproved(approved); n.setFromUser(fromUser); n.setReactionType(reactionType); + n.setContent(content); return notificationRepository.save(n); } diff --git a/src/main/java/com/openisle/service/PostService.java b/src/main/java/com/openisle/service/PostService.java index adb2bb8ea..a4f659d21 100644 --- a/src/main/java/com/openisle/service/PostService.java +++ b/src/main/java/com/openisle/service/PostService.java @@ -109,10 +109,10 @@ public class PostService { java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); for (User admin : admins) { notificationService.createNotification(admin, - NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null); + NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null, null); } notificationService.createNotification(author, - NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null); + NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null, null); } // notify followers of author for (User u : subscriptionService.getSubscribers(author.getUsername())) { @@ -124,6 +124,7 @@ public class PostService { null, null, author, + null, null); } } @@ -151,9 +152,9 @@ public class PostService { if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) { User viewerUser = userRepository.findByUsername(viewer).orElse(null); if (viewerUser != null) { - notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null); + notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null, null); } else { - notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null); + notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, null, null, null); } } return post; @@ -321,7 +322,7 @@ public class PostService { } post.setStatus(PostStatus.PUBLISHED); post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true); + notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true, null, null, null); return post; } @@ -341,7 +342,7 @@ public class PostService { } post.setStatus(PostStatus.REJECTED); post = postRepository.save(post); - notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false); + notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false, null, null, null); return post; } diff --git a/src/main/java/com/openisle/service/ReactionService.java b/src/main/java/com/openisle/service/ReactionService.java index 95ba9af99..371028853 100644 --- a/src/main/java/com/openisle/service/ReactionService.java +++ b/src/main/java/com/openisle/service/ReactionService.java @@ -40,7 +40,7 @@ public class ReactionService { reaction.setType(type); reaction = reactionRepository.save(reaction); if (!user.getId().equals(post.getAuthor().getId())) { - notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type); + notificationService.createNotification(post.getAuthor(), NotificationType.REACTION, post, null, null, user, type, null); } return reaction; } @@ -63,7 +63,7 @@ public class ReactionService { reaction.setType(type); reaction = reactionRepository.save(reaction); if (!user.getId().equals(comment.getAuthor().getId())) { - notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type); + notificationService.createNotification(comment.getAuthor(), NotificationType.REACTION, comment.getPost(), comment, null, user, type, null); } return reaction; } diff --git a/src/main/java/com/openisle/service/RegisterModeService.java b/src/main/java/com/openisle/service/RegisterModeService.java new file mode 100644 index 000000000..f12447a00 --- /dev/null +++ b/src/main/java/com/openisle/service/RegisterModeService.java @@ -0,0 +1,25 @@ +package com.openisle.service; + +import com.openisle.model.RegisterMode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * Holds current registration mode. Configurable at runtime. + */ +@Service +public class RegisterModeService { + private RegisterMode registerMode; + + public RegisterModeService(@Value("${app.register.mode:WHITELIST}") RegisterMode registerMode) { + this.registerMode = registerMode; + } + + public RegisterMode getRegisterMode() { + return registerMode; + } + + public void setRegisterMode(RegisterMode mode) { + this.registerMode = mode; + } +} diff --git a/src/main/java/com/openisle/service/SubscriptionService.java b/src/main/java/com/openisle/service/SubscriptionService.java index 959f11777..8f31a78c5 100644 --- a/src/main/java/com/openisle/service/SubscriptionService.java +++ b/src/main/java/com/openisle/service/SubscriptionService.java @@ -28,7 +28,7 @@ public class SubscriptionService { ps.setPost(post); if (!user.getId().equals(post.getAuthor().getId())) { notificationService.createNotification(post.getAuthor(), - NotificationType.POST_SUBSCRIBED, post, null, null, user, null); + NotificationType.POST_SUBSCRIBED, post, null, null, user, null, null); } return postSubRepo.save(ps); }); @@ -41,7 +41,7 @@ public class SubscriptionService { postSubRepo.delete(ps); if (!user.getId().equals(post.getAuthor().getId())) { notificationService.createNotification(post.getAuthor(), - NotificationType.POST_UNSUBSCRIBED, post, null, null, user, null); + NotificationType.POST_UNSUBSCRIBED, post, null, null, user, null, null); } }); } @@ -72,7 +72,7 @@ public class SubscriptionService { us.setSubscriber(subscriber); us.setTarget(target); notificationService.createNotification(target, - NotificationType.USER_FOLLOWED, null, null, null, subscriber, null); + NotificationType.USER_FOLLOWED, null, null, null, subscriber, null, null); return userSubRepo.save(us); }); } @@ -83,7 +83,7 @@ public class SubscriptionService { userSubRepo.findBySubscriberAndTarget(subscriber, target).ifPresent(us -> { userSubRepo.delete(us); notificationService.createNotification(target, - NotificationType.USER_UNFOLLOWED, null, null, null, subscriber, null); + NotificationType.USER_UNFOLLOWED, null, null, null, subscriber, null, null); }); } diff --git a/src/main/java/com/openisle/service/UserService.java b/src/main/java/com/openisle/service/UserService.java index bcf241e64..1a83aceac 100644 --- a/src/main/java/com/openisle/service/UserService.java +++ b/src/main/java/com/openisle/service/UserService.java @@ -22,7 +22,7 @@ public class UserService { private final UsernameValidator usernameValidator; private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); - public User register(String username, String email, String password) { + public User register(String username, String email, String password, String reason, com.openisle.model.RegisterMode mode) { usernameValidator.validate(username); passwordValidator.validate(password); // ── 先按用户名查 ────────────────────────────────────────── @@ -36,6 +36,8 @@ public class UserService { u.setEmail(email); // 若不允许改邮箱可去掉 u.setPassword(passwordEncoder.encode(password)); u.setVerificationCode(genCode()); + u.setRegisterReason(reason); + u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); return userRepository.save(u); } @@ -50,6 +52,8 @@ public class UserService { u.setUsername(username); // 若不允许改用户名可去掉 u.setPassword(passwordEncoder.encode(password)); u.setVerificationCode(genCode()); + u.setRegisterReason(reason); + u.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); return userRepository.save(u); } @@ -62,6 +66,8 @@ public class UserService { user.setVerified(false); user.setVerificationCode(genCode()); user.setAvatar("https://github.com/identicons/" + username + ".png"); + user.setRegisterReason(reason); + user.setApproved(mode == com.openisle.model.RegisterMode.DIRECT); return userRepository.save(user); } @@ -84,6 +90,7 @@ public class UserService { public Optional authenticate(String username, String password) { return userRepository.findByUsername(username) .filter(User::isVerified) + .filter(User::isApproved) .filter(user -> passwordEncoder.matches(password, user.getPassword())); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 32f55533f..7c2908883 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,6 +13,9 @@ app.password.strength=${PASSWORD_STRENGTH:LOW} # Post publish mode: DIRECT or REVIEW app.post.publish-mode=${POST_PUBLISH_MODE:DIRECT} +# User register mode: DIRECT or WHITELIST +app.register.mode=${REGISTER_MODE:WHITELIST} + # Image upload configuration app.upload.check-type=${UPLOAD_CHECK_TYPE:true} app.upload.max-size=${UPLOAD_MAX_SIZE:5242880} diff --git a/src/test/java/com/openisle/controller/AuthControllerTest.java b/src/test/java/com/openisle/controller/AuthControllerTest.java index 3a58fdb12..ffbfdd3d9 100644 --- a/src/test/java/com/openisle/controller/AuthControllerTest.java +++ b/src/test/java/com/openisle/controller/AuthControllerTest.java @@ -2,6 +2,7 @@ package com.openisle.controller; import com.openisle.model.User; import com.openisle.service.*; +import com.openisle.model.RegisterMode; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -36,6 +37,8 @@ class AuthControllerTest { private CaptchaService captchaService; @MockBean private GoogleAuthService googleAuthService; + @MockBean + private RegisterModeService registerModeService; @Test void registerSendsEmail() throws Exception { @@ -43,11 +46,12 @@ class AuthControllerTest { user.setEmail("a@b.com"); user.setUsername("u"); user.setVerificationCode("123456"); - Mockito.when(userService.register(eq("u"), eq("a@b.com"), eq("p"))).thenReturn(user); + Mockito.when(registerModeService.getRegisterMode()).thenReturn(RegisterMode.DIRECT); + Mockito.when(userService.register(eq("u"), eq("a@b.com"), eq("p"), any(), eq(RegisterMode.DIRECT))).thenReturn(user); mockMvc.perform(post("/api/auth/register") .contentType(MediaType.APPLICATION_JSON) - .content("{\"username\":\"u\",\"email\":\"a@b.com\",\"password\":\"p\"}")) + .content("{\"username\":\"u\",\"email\":\"a@b.com\",\"password\":\"p\",\"reason\":\"test reason more than twenty\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").exists()); diff --git a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java index bffd99f38..b49704459 100644 --- a/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java +++ b/src/test/java/com/openisle/integration/ComplexFlowIntegrationTest.java @@ -17,7 +17,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "app.register.mode=DIRECT") class ComplexFlowIntegrationTest { @Autowired @@ -33,7 +34,7 @@ class ComplexFlowIntegrationTest { HttpHeaders h = new HttpHeaders(); h.setContentType(MediaType.APPLICATION_JSON); rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123"), h), Map.class); + Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); User u = users.findByUsername(username).orElseThrow(); if (u.getVerificationCode() != null) { rest.postForEntity("/api/auth/verify", new HttpEntity<>( diff --git a/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java index d7755f6c0..2c0f30fcf 100644 --- a/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java +++ b/src/test/java/com/openisle/integration/PublishModeIntegrationTest.java @@ -18,7 +18,7 @@ import static org.junit.jupiter.api.Assertions.*; /** Integration tests for review publish mode. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, - properties = "app.post.publish-mode=REVIEW") + properties = {"app.post.publish-mode=REVIEW","app.register.mode=DIRECT"}) class PublishModeIntegrationTest { @Autowired @@ -34,7 +34,7 @@ class PublishModeIntegrationTest { HttpHeaders h = new HttpHeaders(); h.setContentType(MediaType.APPLICATION_JSON); rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123"), h), Map.class); + Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); User u = users.findByUsername(username).orElseThrow(); if (u.getVerificationCode() != null) { rest.postForEntity("/api/auth/verify", new HttpEntity<>( diff --git a/src/test/java/com/openisle/integration/SearchIntegrationTest.java b/src/test/java/com/openisle/integration/SearchIntegrationTest.java index 233d725d0..e06849bd3 100644 --- a/src/test/java/com/openisle/integration/SearchIntegrationTest.java +++ b/src/test/java/com/openisle/integration/SearchIntegrationTest.java @@ -16,7 +16,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "app.register.mode=DIRECT") class SearchIntegrationTest { @Autowired private TestRestTemplate rest; @@ -29,7 +30,7 @@ class SearchIntegrationTest { HttpHeaders h = new HttpHeaders(); h.setContentType(MediaType.APPLICATION_JSON); rest.postForEntity("/api/auth/register", new HttpEntity<>( - Map.of("username", username, "email", email, "password", "pass123"), h), Map.class); + Map.of("username", username, "email", email, "password", "pass123", "reason", "integration test reason more than twenty"), h), Map.class); User u = users.findByUsername(username).orElseThrow(); if (u.getVerificationCode() != null) { rest.postForEntity("/api/auth/verify", new HttpEntity<>( From 40331886df0817a54ad12aacd42cec73b6912d12 Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:39:01 +0800 Subject: [PATCH 4/7] feat: add admin site stats page with DAU chart --- open-isle-cli/package-lock.json | 78 +++++++++++++++++++ open-isle-cli/package.json | 4 +- .../src/components/MenuComponent.vue | 9 +++ open-isle-cli/src/router/index.js | 6 ++ open-isle-cli/src/views/SiteStatsPageView.vue | 51 ++++++++++++ .../openisle/controller/StatController.java | 12 +++ .../repository/UserVisitRepository.java | 5 ++ .../openisle/service/UserVisitService.java | 20 +++++ .../controller/StatControllerTest.java | 20 +++++ 9 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 open-isle-cli/src/views/SiteStatsPageView.vue 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..6be0f190c 100644 --- a/open-isle-cli/src/components/MenuComponent.vue +++ b/open-isle-cli/src/components/MenuComponent.vue @@ -17,6 +17,15 @@ 关于 + + + 站点统计 + 发帖 diff --git a/open-isle-cli/src/router/index.js b/open-isle-cli/src/router/index.js index 8a5a6cc8f..bde81096b 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' @@ -26,6 +27,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/SiteStatsPageView.vue b/open-isle-cli/src/views/SiteStatsPageView.vue new file mode 100644 index 000000000..94467565f --- /dev/null +++ b/open-isle-cli/src/views/SiteStatsPageView.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/src/main/java/com/openisle/controller/StatController.java b/src/main/java/com/openisle/controller/StatController.java index 0e05ca686..742c09a06 100644 --- a/src/main/java/com/openisle/controller/StatController.java +++ b/src/main/java/com/openisle/controller/StatController.java @@ -9,6 +9,7 @@ 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 @@ -23,4 +24,15 @@ public class StatController { 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 9f05ecbeb..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; @@ -11,4 +13,7 @@ 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 29c5cdefe..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 @@ -37,4 +39,22 @@ public class UserVisitService { 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/StatControllerTest.java b/src/test/java/com/openisle/controller/StatControllerTest.java index a44df193f..cdbcd05ae 100644 --- a/src/test/java/com/openisle/controller/StatControllerTest.java +++ b/src/test/java/com/openisle/controller/StatControllerTest.java @@ -51,4 +51,24 @@ class StatControllerTest { .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)); + } } From dc238f1522a4cb2bbb910e30641ace44bd3c5b08 Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 14 Jul 2025 21:55:41 +0800 Subject: [PATCH 5/7] feat: dau sort logic --- open-isle-cli/src/components/MenuComponent.vue | 5 ++++- open-isle-cli/src/views/SiteStatsPageView.vue | 2 ++ src/main/java/com/openisle/controller/StatController.java | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/open-isle-cli/src/components/MenuComponent.vue b/open-isle-cli/src/components/MenuComponent.vue index 6be0f190c..53bf75254 100644 --- a/open-isle-cli/src/components/MenuComponent.vue +++ b/open-isle-cli/src/components/MenuComponent.vue @@ -18,7 +18,7 @@ 关于 99 ? '99+' : this.unreadCount + }, + shouldShowStats() { + return authState.role === 'ADMIN' } }, async mounted() { diff --git a/open-isle-cli/src/views/SiteStatsPageView.vue b/open-isle-cli/src/views/SiteStatsPageView.vue index 94467565f..8c420775a 100644 --- a/open-isle-cli/src/views/SiteStatsPageView.vue +++ b/open-isle-cli/src/views/SiteStatsPageView.vue @@ -25,6 +25,7 @@ async function loadData() { }) if (res.ok) { const data = await res.json() + 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 = { @@ -45,6 +46,7 @@ onMounted(loadData) .site-stats-page { padding: 20px; max-width: var(--page-max-width); + background-color: var(--background-color); margin: 0 auto; height: calc(100vh - var(--header-height) - 40px); } diff --git a/src/main/java/com/openisle/controller/StatController.java b/src/main/java/com/openisle/controller/StatController.java index 742c09a06..1956f57d8 100644 --- a/src/main/java/com/openisle/controller/StatController.java +++ b/src/main/java/com/openisle/controller/StatController.java @@ -32,7 +32,10 @@ public class StatController { 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())) + .map(e -> Map.of( + "date", e.getKey().toString(), + "value", e.getValue() + )) .toList(); } } From a1d358bb301c5ea74e5a81a8f57389ab16de6faf Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:56:27 +0800 Subject: [PATCH 6/7] Allow unauthenticated access to reaction types --- src/main/java/com/openisle/config/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/openisle/config/SecurityConfig.java b/src/main/java/com/openisle/config/SecurityConfig.java index 2dd18b7ce..0a769fc35 100644 --- a/src/main/java/com/openisle/config/SecurityConfig.java +++ b/src/main/java/com/openisle/config/SecurityConfig.java @@ -98,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") @@ -126,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); From d5b391cc3a08b6b5c21810200dfd39d836f0fd71 Mon Sep 17 00:00:00 2001 From: tim Date: Mon, 14 Jul 2025 22:00:41 +0800 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8Dreactions?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- open-isle-cli/src/components/ReactionsGroup.vue | 2 +- src/main/java/com/openisle/controller/StatController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/src/main/java/com/openisle/controller/StatController.java b/src/main/java/com/openisle/controller/StatController.java index 1956f57d8..95abdf577 100644 --- a/src/main/java/com/openisle/controller/StatController.java +++ b/src/main/java/com/openisle/controller/StatController.java @@ -34,7 +34,7 @@ public class StatController { return data.entrySet().stream() .map(e -> Map.of( "date", e.getKey().toString(), - "value", e.getValue() + "value", e.getValue() )) .toList(); }